diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2477449..808b1fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,7 +66,7 @@ jobs: # commonTest is executed by the Android debug unit-test task. A failing # test here blocks the PR. - name: Run JVM/common unit tests - run: ./gradlew :sharingan:testDebugUnitTest :sharingan-noop:testDebugUnitTest + run: ./gradlew :sharingan:testDebugUnitTest :sharingan-db:testDebugUnitTest :sharingan-noop:testDebugUnitTest # Full multiplatform build + iOS simulator unit tests on macOS — the only # host that can link the Kotlin/Native iOS frameworks. This job (a) runs @@ -107,7 +107,7 @@ jobs: # default. If a runtime lacks the device the step fails fast (no silent # skip) — a deviceId follow-up, not a hidden gap. - name: Run iOS simulator unit tests - run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test + run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-db:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test # Android instrumented (connected) tests on a Linux-hosted emulator. This is # the only job that exercises the device suites — :sharingan's Compose UI diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index be9df96..cd3ac23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,6 +84,6 @@ jobs: echo "::notice title=Staged to Central Portal::Artifacts uploaded to a NEW deployment on the Central Portal and are NOT yet public." echo "Next: a maintainer must verify and release manually. See docs/RELEASING.md." echo " 1. Open https://central.sonatype.com/publishing/deployments" - echo " 2. Confirm io.github.mibrahimdev:sharingan and io.github.mibrahimdev:sharingan-noop are present, validated, and signed." + echo " 2. Confirm io.github.mibrahimdev:sharingan, io.github.mibrahimdev:sharingan-db, and io.github.mibrahimdev:sharingan-noop are present, validated, and signed." echo " 3. Click Publish on the deployment to release it to Central." echo " 4. Cut the matching GitHub Release for this tag." diff --git a/AGENTS.md b/AGENTS.md index a624a63..89d4c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Sharingan — agent reference -On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events, never persisted) and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. +On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder — bodies are never written to disk — and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. ## Artifacts @@ -103,7 +103,7 @@ presentSharingan(animated: Boolean = true) // presents over topmost VC, any thr ## Facts agents commonly need - Zero-setup init on Android via manifest-merged ContentProvider; no Application code. -- Buffer is memory-only; process death clears it. Capacity via `SharinganStore(capacity)`. +- Ring buffer is memory-only; process death clears it (the on-disk flight recorder survives). Bodies are never written to disk. Capacity via `SharinganStore(capacity)`. - The Ktor plugin rethrows transport failures untouched after recording them (statusCode null, error set). - Thread-safe: store updates are atomic CAS; loggers callable from any thread. - Min targets: Android API 24, iOS arm64 + simulator arm64. Requires Ktor 3.x for the plugin. diff --git a/CONTEXT.md b/CONTEXT.md index 5c794d4..dfe34ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,17 +18,29 @@ The single home for everything Sharingan knows about one Protocol: its filter ch _Avoid_: handler, strategy, adapter (reserved for things satisfying an interface at a seam) **Capture**: -The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store. +The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store, and redaction applies before disk too. _Avoid_: logging, tracking, interception +**Flight Recorder**: +The on-disk SQLite mirror of the Store, written via the Write-Behind Seam. Memory stays the source of truth; the Flight Recorder is a post-mortem log, not a database to query. Request/response bodies are never written to disk. +_Avoid_: database, cache + **Logger**: A per-Protocol capture entry point (`Sharingan.http`, `Sharingan.mqtt`, `Sharingan.ble`). Client-agnostic: host apps wire their own MQTT/BLE libraries into it. _Avoid_: tracker, recorder +**Run**: +One process lifetime — the Flight Recorder groups all Events captured in a process under a single `session` row. +_Avoid_: session (reserved for the v2 epic) + **Store**: -The in-memory ring buffer of Events (default 300). Memory-only by design — nothing is ever written to disk; process death clears it. +The in-memory ring buffer of Events (default 300). The source of truth while the process lives; each Event is mirrored to the Flight Recorder via the Write-Behind Seam. Process death clears the ring buffer, not the Flight Recorder. _Avoid_: database, cache, repository, buffer (alone) +**Write-Behind Seam**: +The internal seam (`SharinganStore.onRecord` → `PersistenceController`) that mirrors each accepted Event to the Flight Recorder after the ring-buffer append. It observes Capture, never blocks it. +_Avoid_: listener, hook + **Capture Notification**: The Android sticky notification showing per-Protocol counters, a three-event Ticker when expanded, and Pause/Resume. Silent, updated in place, and must never crash the host app. _Avoid_: status bar entry, foreground notification (it isn't a foreground service) diff --git a/README.md b/README.md index a9726fa..401775a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ No init code — a manifest-merged ContentProvider starts Sharingan automaticall - **Detail view per event** — timing waterfall, request/response headers (secrets redacted at capture), syntax-colored JSON bodies, QoS/retain flags, GATT operations and decoded values. - **Share sheet built for agents** — "Copy for AI agent" produces structured Markdown an LLM parses reliably; cURL, raw JSON and the system share sheet are one tap away. - **Zero release impact** — swap in the `sharingan-noop` artifact: identical API, captures nothing, ships no UI. -- **Memory-only** — an in-memory ring buffer (default 300 events); nothing is ever written to disk. +- **Ring buffer, not a database** — events live in an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder (debug builds only); bodies are never written to disk. - **Minimal** — no image assets, no icon fonts, no bundled typefaces, no DI, no navigation library. The capture core depends only on coroutines (+ Ktor when you use the plugin). Requirements: Android API 24+, iOS arm64 + simulator arm64, Ktor 3.x for the HTTP plugin. @@ -336,7 +336,7 @@ Sharingan.clear() // drop everything Sharingan.setNotificationEnabled(false) // Android: opt out of the notification ``` -`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears it). Loggers are thread-safe, callable from any thread. +`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears the ring, not the flight recorder). Loggers are thread-safe, callable from any thread. ## Release builds: what "no effect" means diff --git a/build.gradle.kts b/build.gradle.kts index 1e2e9e4..f2951f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,12 +22,7 @@ apiValidation { // Custom ktlint ruleset (AGENTS.md comments policy) — internal tooling. ignoredProjects += "ktlint-rules" - // Flight-recorder persistence (issue #27/#49) is SQLDelight-generated code - // that the compiler emits `public` (SQLDelight offers no internal-generation - // option). It is a debug-only internal seam, not consumer API, so it is - // excluded from the guarded public surface. Wrapped behind `internal` - // DriverFactory/controller symbols; never mirrored in :sharingan-noop. - ignoredPackages += "dev.sharingan.persistence" + // `:sharingan-db` ships to Maven Central as a dependency of `:sharingan`, so its ABI is guarded like any published module. // KMP: also dump/verify the Kotlin/Native (iOS) ABI, not just the JVM/Android // surface, so iosMain-only declarations (SharinganViewController, @@ -78,13 +73,11 @@ tasks.register("checkApiParity") { // directly, so :sharingan-noop correctly omits them. Excluded from parity: // - dev/sharingan/ui/** : the in-app Compose viewer screens // - dev/sharingan/internal/** : init ContentProvider + notification receiver - // - dev/sharingan/persistence/** : SQLDelight-generated flight-recorder DB (public in bytecode only) // - dev/sharingan/SharinganActivity : the Android viewer Activity // - any ComposableSingletons$* : Compose-compiler lambda synthetics fun isExcludedJvmClass(name: String): Boolean = name.startsWith("dev/sharingan/ui/") || name.startsWith("dev/sharingan/internal/") || - name.startsWith("dev/sharingan/persistence/") || name == "dev/sharingan/SharinganActivity" || name.contains("ComposableSingletons\$") @@ -135,13 +128,12 @@ tasks.register("checkApiParity") { // - `$stableprop` / `$stableprop_getter`: the native equivalent of the JVM // `$stable` field, present only in the Compose-carrying real module; // - `dev.sharingan.ui/SharinganScreen`: the top-level UI composable (ui/**). - // - `dev.sharingan.persistence/`: SQLDelight-generated flight-recorder DB (ui-adjacent internal seam). fun nativeContractEntries(klibFile: File): Set { val entries = linkedSetOf() for (raw in klibFile.readLines()) { val t = raw.trim() if (t.isEmpty() || t == "}" || t.startsWith("//")) continue - if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw || "dev.sharingan.persistence/" in raw) continue + if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw) continue entries.add(t) } return entries diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8323ae8..0ef5df0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ Companion docs: [README.md](../README.md) (user-facing), [AGENTS.md](../AGENTS.m ## 1. System overview -Sharingan is an on-device debug logger for Kotlin Multiplatform (Android API 24+, iOS arm64 + simulator arm64). It captures HTTP, MQTT and BLE traffic into an in-memory ring buffer, renders a Compose Multiplatform log browser, and exports events as agent-friendly Markdown / cURL / JSON / digest text. Nothing is ever persisted. +Sharingan is an on-device debug logger for Kotlin Multiplatform (Android API 24+, iOS arm64 + simulator arm64). It captures HTTP, MQTT and BLE traffic into an in-memory ring buffer, renders a Compose Multiplatform log browser, and exports events as agent-friendly Markdown / cURL / JSON / digest text. Events live in an in-memory ring buffer (default 300) that is also mirrored to an on-device SQLite flight recorder via a write-behind seam; persistence is on by default in debug, and request/response bodies are never written to disk. ### Module map @@ -105,7 +105,7 @@ All deliberate, all verifiable: - **Streaming never consumed.** `shouldReadBody()` refuses `text/event-stream` and anything non-textual (`isTextual()` whitelist: `text/*`, `*json`, `*xml`, form-urlencoded) — SSE and binary downloads keep streaming for the caller. - **Transport failures recorded then rethrown untouched** (catch block in the plugin's `on(Send)`): the app's error handling is never altered. - **Notification failures swallowed.** `manager.notify()` is wrapped in `try/catch(Exception)` in `CaptureNotification.post()`. This is a scar, not paranoia: commit `8550bbd` records a real crash where the API-26 version guard left the builder chain (`.setSmallIcon` etc.) attached only to the `else` branch, so API 26+ posted an icon-less notification and the resulting `IllegalArgumentException` from a background flow collector killed the host app. Rule extracted: **a debug tool must never crash the host app.** -- **Memory-only ring buffer.** No disk, no network. Process death clears everything (a stated property, see §6). +- **Memory-only ring buffer, mirrored on disk.** The ring buffer never touches the network; each event is mirrored to the SQLite flight recorder (§5.4). Process death clears the ring, not the recorder (a stated property, see §6). ### 2.5 UI architecture @@ -219,9 +219,9 @@ For **WebSocket capture** specifically: Ktor's `WebSockets` plugin offers no equ The design explored three densities — **Terminal / Comfortable / Badged** — and only Terminal shipped (`TerminalRow` in [ui/HomeScreen.kt](../sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt); its doc comment names it "the design's default 'Terminal' density"). To add a variant: new `ComfortableRow`/`BadgedRow` composable beside `TerminalRow` consuming the same `EventPresentation`, a density value in `HomeUiState`, and a row-style picker in `HomeHeader`. `EventPresentation` already carries everything (`sub`, `sizeLabel`, tints) the richer variants need. -### 5.4 Persist events +### 5.4 Flight recorder (persistence) -Today the buffer is deliberately memory-only. If persistence is ever wanted: keep `SharinganStore` as the hot interface and add an optional sink observing `store.events` (the pattern `CaptureNotification.start()` already uses); never make persistence the source of truth. Mind: redaction already happened at capture, but bodies may still hold PII — persisting changes the security posture, opt-in only. +The in-memory ring buffer stays the source of truth. Each accepted Event is also mirrored to an on-device SQLite database by a write-behind seam: `SharinganStore.onRecord` (internal callback, invoked after the ring-buffer CAS append) → `PersistenceController` ([sharingan-db](../sharingan-db), SQLDelight). Persistence ships ON by default in debug. The Flight Recorder is a post-mortem log, never queried back into the UI and never the source of truth; one session row groups the Events of each process lifetime (a Run). Redaction applies before disk, and request/response bodies are never written to disk — persisting bodies would change the security posture and is deliberately out of scope. ### 5.5 Sample iosApp Xcode project @@ -234,7 +234,7 @@ Add `sample/iosApp/` (standard KMP template: `iosApp.xcodeproj` + SwiftUI `Conte - **`SharinganScreen()` exists only in the debug artifact.** It is the one asymmetry in the API surface (deliberate: a noop composable would drag Compose into the release artifact). Never reference it from code compiled against the noop; use `Sharingan.show(context)` / `SharinganViewController()`, which *are* mirrored. Documented in [AGENTS.md](../AGENTS.md) and README §"Release builds". - **DND hides the notification.** The channel is `IMPORTANCE_LOW` and the notification silent (`CaptureNotification.ensureChannel`), so Do Not Disturb suppresses it on most devices. `Sharingan.show(context)` always works. - **`POST_NOTIFICATIONS` must be requested by the host app** on Android 13+. The library only *declares* the permission ([sharingan/src/androidMain/AndroidManifest.xml](../sharingan/src/androidMain/AndroidManifest.xml)); the sample shows the request ([MainActivity.kt](../sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt)). Without the grant, capture still works (`areNotificationsEnabled()` check + swallowed `notify()` failures), there's just no notification. -- **Buffer lost on process death** — memory-only by design; capacity is per-store (`SharinganStore(capacity = …)`). +- **Ring buffer lost on process death** — memory-only by design, so the ring resets each process; the flight recorder survives (§5.4). Capacity is per-store (`SharinganStore(capacity = …)`). - **The notification observer starts once per process** (`CaptureNotification.start` guards on `scope != null`) and survives for the process lifetime; `setNotificationEnabled(false)` cancels the posted notification but keeps observing. - **`HttpEvent.host/path` come from a naive string split** (`splitUrl` in `HttpEvent.kt`), not a URL parser — fine for display, don't reuse it for anything semantic. - **Request-body capture only sees `OutgoingContent.ByteArrayContent`** (`outgoingBodyText` in `ktor/SharinganKtor.kt`); streamed/chunked request bodies are not captured (responses: textual-only whitelist, SSE never read). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6169873..7709aa5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,11 +1,12 @@ # Releasing Sharingan -Sharingan ships two artifacts to Maven Central under `io.github.mibrahimdev`: +Sharingan ships three artifacts to Maven Central under `io.github.mibrahimdev`: -| Coordinate | Module | -| ------------------------------------- | ----------------- | -| `io.github.mibrahimdev:sharingan` | `:sharingan` | -| `io.github.mibrahimdev:sharingan-noop`| `:sharingan-noop` | +| Coordinate | Module | +| -------------------------------------- | ----------------- | +| `io.github.mibrahimdev:sharingan` | `:sharingan` | +| `io.github.mibrahimdev:sharingan-db` | `:sharingan-db` | +| `io.github.mibrahimdev:sharingan-noop` | `:sharingan-noop` | Releases are **two steps by deliberate design**: CI *stages* a deployment to the Central Portal, then a human *verifies and releases* it. We do **not** @@ -37,7 +38,7 @@ push tag v ──▶ CI stages to Central Portal ──▶ STOP (aw (`scripts/check-version-tag.sh`). 3. The `Publish to Maven Central` workflow runs `./gradlew publishToMavenCentral`. - This **uploads both modules to a new deployment on the Central Portal and + This **uploads all three modules to a new deployment on the Central Portal and stops** — nothing is public yet. The job's `Staged - next steps` summary links straight back to this checklist. @@ -48,12 +49,13 @@ push tag v ──▶ CI stages to Central Portal ──▶ STOP (aw 2. Find the new deployment from the CI run. Confirm **all** of: - **Status** is `VALIDATED` (not `FAILED`). If it failed, expand it to read why — do **not** publish a failed deployment; fix and re-stage instead. - - **Both** components are present under group `io.github.mibrahimdev`: - - `io.github.mibrahimdev:sharingan:` - - `io.github.mibrahimdev:sharingan-noop:` + - **All three** components are present under group `io.github.mibrahimdev`: + - `io.github.mibrahimdev:sharingan:` + - `io.github.mibrahimdev:sharingan-db:` + - `io.github.mibrahimdev:sharingan-noop:` - If only one module is there, something is wrong — drop the deployment and - re-stage. Never release a half-uploaded version. + If any module is missing, something is wrong — drop the deployment and + re-stage. Never release a half-uploaded version. - The **version** matches the tag (``, no stray `-SNAPSHOT`). - Each component carries its **`.pom`**, the main artifact, **`-sources`** and **`-javadoc`** jars, and a matching **`.asc` signature** for every diff --git a/docs/api-parity.md b/docs/api-parity.md index 7335909..f2e42ce 100644 --- a/docs/api-parity.md +++ b/docs/api-parity.md @@ -48,6 +48,18 @@ Kotlin/Native link, no Android or iOS SDK — so it runs in a couple of seconds. ./gradlew checkApiParity ``` +### What BCV itself excludes + +`apiCheck`/`apiDump` are also configured in the root `build.gradle.kts`. The +following modules are intentionally **not** BCV-guarded, because they are not +consumer-facing public API: + +- `:sample:composeApp` — the sample application, not a published library. +- `:sharingan-db` — the on-device SQLDelight database is an implementation +detail of `:sharingan`. Its generated surface changes with every schema edit in +slices 3–5, and no consumer codes against it. It is published only as a +required runtime dependency of `:sharingan`. + In CI it runs as the `api-parity` job in [`.github/workflows/api-check.yml`](../.github/workflows/api-check.yml), on a cheap `ubuntu` runner (it needs no macOS host). diff --git a/llms.txt b/llms.txt index b02d756..340f484 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Sharingan — agent reference -On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events, never persisted) and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. +On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder — bodies are never written to disk — and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted. ## Artifacts @@ -103,7 +103,7 @@ presentSharingan(animated: Boolean = true) // presents over topmost VC, any thr ## Facts agents commonly need - Zero-setup init on Android via manifest-merged ContentProvider; no Application code. -- Buffer is memory-only; process death clears it. Capacity via `SharinganStore(capacity)`. +- Ring buffer is memory-only; process death clears it (the on-disk flight recorder survives). Bodies are never written to disk. Capacity via `SharinganStore(capacity)`. - The Ktor plugin rethrows transport failures untouched after recording them (statusCode null, error set). - Thread-safe: store updates are atomic CAS; loggers callable from any thread. - Min targets: Android API 24, iOS arm64 + simulator arm64. Requires Ktor 3.x for the plugin. diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a1f064..5c6af11 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,5 @@ rootProject.name = "sharingan" include(":sharingan") include(":sharingan-noop") include(":ktlint-rules") +include(":sharingan-db") include(":sample:composeApp") diff --git a/sharingan-db/api/sharingan-db.api b/sharingan-db/api/sharingan-db.api new file mode 100644 index 0000000..e4e00d8 --- /dev/null +++ b/sharingan-db/api/sharingan-db.api @@ -0,0 +1,106 @@ +public final class dev/sharingan/db/Event { + public fun (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()J + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/Event; + public static synthetic fun copy$default (Ldev/sharingan/db/Event;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/Event; + public fun equals (Ljava/lang/Object;)Z + public final fun getHost_or_topic ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getPayload_json ()Ljava/lang/String; + public final fun getSession_id ()Ljava/lang/String; + public final fun getTimestamp ()J + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun is_failure ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/sharingan/db/EventRow { + public fun (Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/EventRow; + public static synthetic fun copy$default (Ldev/sharingan/db/EventRow;Ljava/lang/String;JLjava/lang/String;ZLjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/EventRow; + public fun equals (Ljava/lang/Object;)Z + public final fun getHostOrTopic ()Ljava/lang/String; + public final fun getPayloadJson ()Ljava/lang/String; + public final fun getRawId ()Ljava/lang/String; + public final fun getTimestampMillis ()J + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun isFailure ()Z + public fun toString ()Ljava/lang/String; +} + +public final class dev/sharingan/db/PersistenceController { + public fun (Lkotlin/jvm/functions/Function1;IJ)V + public synthetic fun (Lkotlin/jvm/functions/Function1;IJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun clear ()V + public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun start ()V + public final fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun submit (Ljava/lang/Object;)V +} + +public final class dev/sharingan/db/Session { + public fun (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/sharingan/db/Session; + public static synthetic fun copy$default (Ldev/sharingan/db/Session;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/sharingan/db/Session; + public fun equals (Ljava/lang/Object;)Z + public final fun getApp_id ()Ljava/lang/String; + public final fun getBuild ()Ljava/lang/String; + public final fun getDevice_model ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getOs ()Ljava/lang/String; + public final fun getStarted_at ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/sharingan/db/SharinganDatabase : app/cash/sqldelight/Transacter { + public static final field Companion Ldev/sharingan/db/SharinganDatabase$Companion; + public abstract fun getSharinganDatabaseQueries ()Ldev/sharingan/db/SharinganDatabaseQueries; +} + +public final class dev/sharingan/db/SharinganDatabase$Companion { + public final fun getSchema ()Lapp/cash/sqldelight/db/SqlSchema; + public final fun invoke (Lapp/cash/sqldelight/db/SqlDriver;)Ldev/sharingan/db/SharinganDatabase; +} + +public final class dev/sharingan/db/SharinganDatabaseQueries : app/cash/sqldelight/TransacterImpl { + public fun (Lapp/cash/sqldelight/db/SqlDriver;)V + public final fun deleteAllEvents ()V + public final fun deleteSession (Ljava/lang/String;)V + public final fun insertEvent (Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V + public final fun insertSession (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun selectAllEvents ()Lapp/cash/sqldelight/Query; + public final fun selectAllEvents (Lkotlin/jvm/functions/Function7;)Lapp/cash/sqldelight/Query; + public final fun selectAllSessions ()Lapp/cash/sqldelight/Query; + public final fun selectAllSessions (Lkotlin/jvm/functions/Function6;)Lapp/cash/sqldelight/Query; + public final fun selectEventsForSession (Ljava/lang/String;)Lapp/cash/sqldelight/Query; + public final fun selectEventsForSession (Ljava/lang/String;Lkotlin/jvm/functions/Function7;)Lapp/cash/sqldelight/Query; + public final fun selectSession (Ljava/lang/String;)Lapp/cash/sqldelight/Query; + public final fun selectSession (Ljava/lang/String;Lkotlin/jvm/functions/Function6;)Lapp/cash/sqldelight/Query; +} + +public final class dev/sharingan/db/SharinganDbContext { + public static final field INSTANCE Ldev/sharingan/db/SharinganDbContext; + public final fun install (Landroid/content/Context;)V +} + diff --git a/sharingan-db/api/sharingan-db.klib.api b/sharingan-db/api/sharingan-db.klib.api new file mode 100644 index 0000000..726e9a8 --- /dev/null +++ b/sharingan-db/api/sharingan-db.klib.api @@ -0,0 +1,133 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +abstract interface dev.sharingan.db/SharinganDatabase : app.cash.sqldelight/Transacter { // dev.sharingan.db/SharinganDatabase|null[0] + abstract val sharinganDatabaseQueries // dev.sharingan.db/SharinganDatabase.sharinganDatabaseQueries|{}sharinganDatabaseQueries[0] + abstract fun (): dev.sharingan.db/SharinganDatabaseQueries // dev.sharingan.db/SharinganDatabase.sharinganDatabaseQueries.|(){}[0] + + final object Companion { // dev.sharingan.db/SharinganDatabase.Companion|null[0] + final val Schema // dev.sharingan.db/SharinganDatabase.Companion.Schema|{}Schema[0] + final fun (): app.cash.sqldelight.db/SqlSchema> // dev.sharingan.db/SharinganDatabase.Companion.Schema.|(){}[0] + + final fun invoke(app.cash.sqldelight.db/SqlDriver): dev.sharingan.db/SharinganDatabase // dev.sharingan.db/SharinganDatabase.Companion.invoke|invoke(app.cash.sqldelight.db.SqlDriver){}[0] + } +} + +final class <#A: kotlin/Any> dev.sharingan.db/PersistenceController { // dev.sharingan.db/PersistenceController|null[0] + constructor (kotlin/Function1<#A, dev.sharingan.db/EventRow>, kotlin/Int = ..., kotlin/Long = ...) // dev.sharingan.db/PersistenceController.|(kotlin.Function1<1:0,dev.sharingan.db.EventRow>;kotlin.Int;kotlin.Long){}[0] + + final fun clear() // dev.sharingan.db/PersistenceController.clear|clear(){}[0] + final fun start() // dev.sharingan.db/PersistenceController.start|start(){}[0] + final fun submit(#A) // dev.sharingan.db/PersistenceController.submit|submit(1:0){}[0] + final suspend fun close() // dev.sharingan.db/PersistenceController.close|close(){}[0] + final suspend fun stop() // dev.sharingan.db/PersistenceController.stop|stop(){}[0] +} + +final class dev.sharingan.db/Event { // dev.sharingan.db/Event|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Long, kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?) // dev.sharingan.db/Event.|(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + + final val host_or_topic // dev.sharingan.db/Event.host_or_topic|{}host_or_topic[0] + final fun (): kotlin/String? // dev.sharingan.db/Event.host_or_topic.|(){}[0] + final val id // dev.sharingan.db/Event.id|{}id[0] + final fun (): kotlin/String // dev.sharingan.db/Event.id.|(){}[0] + final val is_failure // dev.sharingan.db/Event.is_failure|{}is_failure[0] + final fun (): kotlin/Long // dev.sharingan.db/Event.is_failure.|(){}[0] + final val payload_json // dev.sharingan.db/Event.payload_json|{}payload_json[0] + final fun (): kotlin/String? // dev.sharingan.db/Event.payload_json.|(){}[0] + final val session_id // dev.sharingan.db/Event.session_id|{}session_id[0] + final fun (): kotlin/String // dev.sharingan.db/Event.session_id.|(){}[0] + final val timestamp // dev.sharingan.db/Event.timestamp|{}timestamp[0] + final fun (): kotlin/Long // dev.sharingan.db/Event.timestamp.|(){}[0] + final val type // dev.sharingan.db/Event.type|{}type[0] + final fun (): kotlin/String // dev.sharingan.db/Event.type.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/Event.component1|component1(){}[0] + final fun component2(): kotlin/String // dev.sharingan.db/Event.component2|component2(){}[0] + final fun component3(): kotlin/Long // dev.sharingan.db/Event.component3|component3(){}[0] + final fun component4(): kotlin/String // dev.sharingan.db/Event.component4|component4(){}[0] + final fun component5(): kotlin/Long // dev.sharingan.db/Event.component5|component5(){}[0] + final fun component6(): kotlin/String? // dev.sharingan.db/Event.component6|component6(){}[0] + final fun component7(): kotlin/String? // dev.sharingan.db/Event.component7|component7(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/String? = ..., kotlin/String? = ...): dev.sharingan.db/Event // dev.sharingan.db/Event.copy|copy(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/Event.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/Event.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/Event.toString|toString(){}[0] +} + +final class dev.sharingan.db/EventRow { // dev.sharingan.db/EventRow|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/String, kotlin/Boolean, kotlin/String?, kotlin/String) // dev.sharingan.db/EventRow.|(kotlin.String;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.String?;kotlin.String){}[0] + + final val hostOrTopic // dev.sharingan.db/EventRow.hostOrTopic|{}hostOrTopic[0] + final fun (): kotlin/String? // dev.sharingan.db/EventRow.hostOrTopic.|(){}[0] + final val isFailure // dev.sharingan.db/EventRow.isFailure|{}isFailure[0] + final fun (): kotlin/Boolean // dev.sharingan.db/EventRow.isFailure.|(){}[0] + final val payloadJson // dev.sharingan.db/EventRow.payloadJson|{}payloadJson[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.payloadJson.|(){}[0] + final val rawId // dev.sharingan.db/EventRow.rawId|{}rawId[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.rawId.|(){}[0] + final val timestampMillis // dev.sharingan.db/EventRow.timestampMillis|{}timestampMillis[0] + final fun (): kotlin/Long // dev.sharingan.db/EventRow.timestampMillis.|(){}[0] + final val type // dev.sharingan.db/EventRow.type|{}type[0] + final fun (): kotlin/String // dev.sharingan.db/EventRow.type.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/EventRow.component1|component1(){}[0] + final fun component2(): kotlin/Long // dev.sharingan.db/EventRow.component2|component2(){}[0] + final fun component3(): kotlin/String // dev.sharingan.db/EventRow.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // dev.sharingan.db/EventRow.component4|component4(){}[0] + final fun component5(): kotlin/String? // dev.sharingan.db/EventRow.component5|component5(){}[0] + final fun component6(): kotlin/String // dev.sharingan.db/EventRow.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/Long = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/String? = ..., kotlin/String = ...): dev.sharingan.db/EventRow // dev.sharingan.db/EventRow.copy|copy(kotlin.String;kotlin.Long;kotlin.String;kotlin.Boolean;kotlin.String?;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/EventRow.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/EventRow.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/EventRow.toString|toString(){}[0] +} + +final class dev.sharingan.db/Session { // dev.sharingan.db/Session|null[0] + constructor (kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/String?) // dev.sharingan.db/Session.|(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + + final val app_id // dev.sharingan.db/Session.app_id|{}app_id[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.app_id.|(){}[0] + final val build // dev.sharingan.db/Session.build|{}build[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.build.|(){}[0] + final val device_model // dev.sharingan.db/Session.device_model|{}device_model[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.device_model.|(){}[0] + final val id // dev.sharingan.db/Session.id|{}id[0] + final fun (): kotlin/String // dev.sharingan.db/Session.id.|(){}[0] + final val os // dev.sharingan.db/Session.os|{}os[0] + final fun (): kotlin/String? // dev.sharingan.db/Session.os.|(){}[0] + final val started_at // dev.sharingan.db/Session.started_at|{}started_at[0] + final fun (): kotlin/Long // dev.sharingan.db/Session.started_at.|(){}[0] + + final fun component1(): kotlin/String // dev.sharingan.db/Session.component1|component1(){}[0] + final fun component2(): kotlin/Long // dev.sharingan.db/Session.component2|component2(){}[0] + final fun component3(): kotlin/String? // dev.sharingan.db/Session.component3|component3(){}[0] + final fun component4(): kotlin/String? // dev.sharingan.db/Session.component4|component4(){}[0] + final fun component5(): kotlin/String? // dev.sharingan.db/Session.component5|component5(){}[0] + final fun component6(): kotlin/String? // dev.sharingan.db/Session.component6|component6(){}[0] + final fun copy(kotlin/String = ..., kotlin/Long = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/String? = ...): dev.sharingan.db/Session // dev.sharingan.db/Session.copy|copy(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // dev.sharingan.db/Session.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // dev.sharingan.db/Session.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // dev.sharingan.db/Session.toString|toString(){}[0] +} + +final class dev.sharingan.db/SharinganDatabaseQueries : app.cash.sqldelight/TransacterImpl { // dev.sharingan.db/SharinganDatabaseQueries|null[0] + constructor (app.cash.sqldelight.db/SqlDriver) // dev.sharingan.db/SharinganDatabaseQueries.|(app.cash.sqldelight.db.SqlDriver){}[0] + + final fun <#A1: kotlin/Any> selectAllEvents(kotlin/Function7): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectAllEvents|selectAllEvents(kotlin.Function7){0§}[0] + final fun <#A1: kotlin/Any> selectAllSessions(kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectAllSessions|selectAllSessions(kotlin.Function6){0§}[0] + final fun <#A1: kotlin/Any> selectEventsForSession(kotlin/String, kotlin/Function7): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectEventsForSession|selectEventsForSession(kotlin.String;kotlin.Function7){0§}[0] + final fun <#A1: kotlin/Any> selectSession(kotlin/String, kotlin/Function6): app.cash.sqldelight/Query<#A1> // dev.sharingan.db/SharinganDatabaseQueries.selectSession|selectSession(kotlin.String;kotlin.Function6){0§}[0] + final fun deleteAllEvents() // dev.sharingan.db/SharinganDatabaseQueries.deleteAllEvents|deleteAllEvents(){}[0] + final fun deleteSession(kotlin/String) // dev.sharingan.db/SharinganDatabaseQueries.deleteSession|deleteSession(kotlin.String){}[0] + final fun insertEvent(kotlin/String, kotlin/String, kotlin/Long, kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertEvent|insertEvent(kotlin.String;kotlin.String;kotlin.Long;kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?){}[0] + final fun insertSession(kotlin/String, kotlin/Long, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/String?) // dev.sharingan.db/SharinganDatabaseQueries.insertSession|insertSession(kotlin.String;kotlin.Long;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.String?){}[0] + final fun selectAllEvents(): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectAllEvents|selectAllEvents(){}[0] + final fun selectAllSessions(): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectAllSessions|selectAllSessions(){}[0] + final fun selectEventsForSession(kotlin/String): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectEventsForSession|selectEventsForSession(kotlin.String){}[0] + final fun selectSession(kotlin/String): app.cash.sqldelight/Query // dev.sharingan.db/SharinganDatabaseQueries.selectSession|selectSession(kotlin.String){}[0] +} diff --git a/sharingan-db/build.gradle.kts b/sharingan-db/build.gradle.kts new file mode 100644 index 0000000..56e5a1c --- /dev/null +++ b/sharingan-db/build.gradle.kts @@ -0,0 +1,97 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.sqldelight) + alias(libs.plugins.mavenPublish) + alias(libs.plugins.ktlint) +} + +group = "io.github.mibrahimdev" +version = libs.versions.sharingan.get() + +sqldelight { + databases { + create("SharinganDatabase") { packageName.set("dev.sharingan.db") } + } +} + +kotlin { + explicitApi() + + androidTarget { + publishLibraryVariants("release") + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + + iosArm64() + iosSimulatorArm64 { + // Same pin as :sharingan — the default KGP simulator id may not exist. + testRuns.configureEach { deviceId = "iPhone 17 Pro" } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.sqldelight.runtime) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + androidMain.dependencies { implementation(libs.sqldelight.android.driver) } + iosMain.dependencies { implementation(libs.sqldelight.native.driver) } + androidUnitTest.dependencies { implementation(libs.sqldelight.sqlite.driver) } + iosTest.dependencies { implementation(libs.sqldelight.native.driver) } + } +} + +android { + namespace = "dev.sharingan.db" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +mavenPublishing { + publishToMavenCentral() + if (!providers.gradleProperty("localPublishNoSign").isPresent) { + signAllPublications() + } + coordinates(group.toString(), "sharingan-db", version.toString()) + pom { + name.set("Sharingan DB") + description.set("On-device flight-recorder database for Sharingan. Implementation detail of the `sharingan` artifact.") + url.set("https://mibrahimdev.github.io/Sharingan/") + licenses { + license { + name.set("Apache-2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("mibrahimdev") + name.set("Mohamed Ibrahim") + email.set("mibrahim.dev@gmail.com") + } + } + scm { + connection.set("scm:git:https://github.com/mibrahimdev/Sharingan.git") + developerConnection.set("scm:git:ssh://git@github.com/mibrahimdev/Sharingan.git") + url.set("https://github.com/mibrahimdev/Sharingan") + } + } +} diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt new file mode 100644 index 0000000..1f1f2cd --- /dev/null +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/DriverFactory.android.kt @@ -0,0 +1,25 @@ +package dev.sharingan.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver + +internal actual class DriverFactory actual constructor() { + actual fun create(): SqlDriver { + val context = + SharinganDbContext.appContext + ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") + return AndroidSqliteDriver( + schema = SharinganDatabase.Schema, + context = context, + name = "sharingan.db", + callback = + object : AndroidSqliteDriver.Callback(SharinganDatabase.Schema) { + override fun onConfigure(db: SupportSQLiteDatabase) { + super.onConfigure(db) + db.setForeignKeyConstraintsEnabled(true) + } + }, + ) + } +} diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt new file mode 100644 index 0000000..1154080 --- /dev/null +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/Now.android.kt @@ -0,0 +1,3 @@ +package dev.sharingan.db + +internal actual fun nowMillis(): Long = System.currentTimeMillis() diff --git a/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt new file mode 100644 index 0000000..fab0677 --- /dev/null +++ b/sharingan-db/src/androidMain/kotlin/dev/sharingan/db/SharinganDbContext.kt @@ -0,0 +1,11 @@ +package dev.sharingan.db + +import android.content.Context + +public object SharinganDbContext { + @Volatile internal var appContext: Context? = null + + public fun install(context: Context) { + appContext = context.applicationContext + } +} diff --git a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt b/sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt similarity index 77% rename from sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt rename to sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt index 2ae33a0..3b3249e 100644 --- a/sharingan/src/androidUnitTest/kotlin/dev/sharingan/persistence/TestSqlDriver.android.kt +++ b/sharingan-db/src/androidUnitTest/kotlin/dev/sharingan/db/TestSqlDriver.android.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver @@ -6,4 +6,5 @@ import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver internal actual fun createTestDriver(): SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply { SharinganDatabase.Schema.create(this) + execute(null, "PRAGMA foreign_keys=ON;", 0) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt similarity index 79% rename from sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt rename to sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt index 5acbcfd..171ce79 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/persistence/DriverFactory.kt +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/DriverFactory.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver @@ -7,6 +7,6 @@ import app.cash.sqldelight.db.SqlDriver * database. Internal: persistence is a debug-only concern and must not leak * into the public API (or the no-op twin). */ -internal expect class DriverFactory { +internal expect class DriverFactory() { fun create(): SqlDriver } diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt new file mode 100644 index 0000000..f92129f --- /dev/null +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/EventRow.kt @@ -0,0 +1,15 @@ +package dev.sharingan.db + +/** + * Plain row handed to the persistence controller. The caller's [toRow] mapping + * runs on the flusher coroutine so the hot record() path stays a lock-free + * channel send. + */ +public data class EventRow( + val rawId: String, + val timestampMillis: Long, + val type: String, + val isFailure: Boolean, + val hostOrTopic: String?, + val payloadJson: String, +) diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt new file mode 100644 index 0000000..da4667d --- /dev/null +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/Now.kt @@ -0,0 +1,4 @@ +package dev.sharingan.db + +/** Current wall-clock time in epoch milliseconds. */ +internal expect fun nowMillis(): Long diff --git a/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt new file mode 100644 index 0000000..94f6d27 --- /dev/null +++ b/sharingan-db/src/commonMain/kotlin/dev/sharingan/db/PersistenceController.kt @@ -0,0 +1,264 @@ +package dev.sharingan.db + +import app.cash.sqldelight.db.SqlDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +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.channels.ClosedReceiveChannelException +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Write-behind persistence: drains events off the caller's seam into an + * on-device SQLDelight database so logs survive process death. + * + * [submit] is a non-blocking [Channel.trySend]; a flusher coroutine batches + * writes by size or time into one transaction per batch, running [toRow] off + * the hot path. + * + * Uses `select`/`onTimeout` ([ExperimentalCoroutinesApi]) — re-validate against + * the controller tests on a coroutines bump. + */ +@OptIn(ExperimentalAtomicApi::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) +public class PersistenceController private constructor( + private val toRow: (T) -> EventRow, + private val driver: SqlDriver, + private val ownsDriver: Boolean, + private val scope: CoroutineScope, + private val batchSize: Int, + private val flushIntervalMillis: Long, + channelCapacity: Int, +) { + public constructor( + toRow: (T) -> EventRow, + batchSize: Int = DEFAULT_BATCH_SIZE, + flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + ) : this( + toRow = toRow, + driver = DriverFactory().create(), + ownsDriver = true, + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + batchSize = batchSize, + flushIntervalMillis = flushIntervalMillis, + channelCapacity = CHANNEL_CAPACITY, + ) + + internal constructor( + toRow: (T) -> EventRow, + driver: SqlDriver, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + batchSize: Int = DEFAULT_BATCH_SIZE, + flushIntervalMillis: Long = DEFAULT_FLUSH_INTERVAL_MILLIS, + channelCapacity: Int = CHANNEL_CAPACITY, + ) : this( + toRow = toRow, + driver = driver, + ownsDriver = false, + scope = scope, + batchSize = batchSize, + flushIntervalMillis = flushIntervalMillis, + channelCapacity = channelCapacity, + ) + + private val database = SharinganDatabase(driver) + private val channel = newEventChannel>(channelCapacity) + + // AtomicReference so a racing stop() can't read a stale null and skip the join. + private val flusherJob = AtomicReference(null) + + // Terminal flag; the controller is single-use once stopped. + private val stopped = AtomicBoolean(false) + + // Flusher-thread only; reset on rollback so a rolled-back session row isn't reused. + private var sessionId: String? = null + + /** Test seam: batch size after each committed transaction. */ + internal var onBatchFlushed: ((Int) -> Unit)? = null + + /** Test seam: invoked after a clear's rows have been deleted. */ + internal var onCleared: (() -> Unit)? = null + + private val flusherStarts = AtomicInt(0) + + /** Test seam: flusher coroutines that actually started. */ + internal fun flusherStartCount(): Int = flusherStarts.load() + + /** + * Wires the seam and starts the flusher. Idempotent before [stop]; after + * [stop] the controller is single-use and [start] throws. + */ + public fun start() { + if (stopped.load()) error("PersistenceController is single-use; already stopped") + // LAZY + start only on the CAS win: an eager launch would need a cancel() that could drop an event. + val job = scope.launch(start = CoroutineStart.LAZY) { runFlusher() } + if (flusherJob.compareAndSet(null, job)) job.start() + } + + /** Submits [event] to the flusher. Never blocks the caller. */ + public fun submit(event: T) { + channel.trySend(Command.Record(event)) + } + + /** + * Deletes every persisted row and discards whatever is still queued or + * held in the flusher's in-flight batch. Enqueued as a command on the same + * channel the writes travel on, so the single flusher coroutine applies it + * at an exact point in the write order — events recorded before [clear] + * are flushed first and then deleted (or discarded), events recorded after + * survive. No locks, no resurrection window. + */ + public fun clear() { + channel.trySend(Command.ClearAll) + } + + /** + * Drains pending events, then stops the flusher. The driver stays open. + * + * WARNING: does NOT detach the caller's `store.onRecord` seam — events + * submitted after [stop] hit a closed channel and are dropped. Detaching the + * seam is the caller's responsibility. + */ + public suspend fun stop() { + stopped.store(true) + // Close first so a start() that won the CAS after stopped=true lands on a closed channel and exits at once. + channel.close() + val job = flusherJob.exchange(null) ?: return + job.join() + } + + /** Closes the driver only if this controller opened it. Like [stop], does not detach the seam. */ + public suspend fun close() { + stop() + scope.cancel() + if (ownsDriver) driver.close() + } + + private suspend fun runFlusher() { + flusherStarts.incrementAndFetch() + val batch = mutableListOf() + var deadline = 0L + while (true) { + val command: Command? = + if (batch.isEmpty()) { + try { + channel.receive() + } catch (e: ClosedReceiveChannelException) { + break + } + } else { + // Atomic receive-or-timeout; withTimeoutOrNull could prompt-cancel an already-taken element. + var closed = false + select?> { + channel.onReceiveCatching { result -> + if (result.isClosed) { + closed = true + null + } else { + result.getOrNull() + } + } + onTimeout(deadline - nowMillis()) { null } + }.also { if (closed) break } + } + when (command) { + is Command.Record -> { + val event = command.event + if (batch.isEmpty()) deadline = nowMillis() + flushIntervalMillis + batch.add(event) + if (batch.size >= batchSize) flush(batch) + } + is Command.ClearAll -> { + batch.clear() + database.sharinganDatabaseQueries.deleteAllEvents() + onCleared?.invoke() + } + null -> flush(batch) + } + } + flush(batch) // drain the in-flight batch after the channel is closed + } + + private fun flush(batch: MutableList) { + if (batch.isEmpty()) return + val snapshot = batch.toList() + batch.clear() + val isNewSession = sessionId == null + val session = sessionId ?: newSessionId() + try { + // Map inside the try so a throwing mapper drops the batch, not the flusher. + val rows = snapshot.map(toRow) + database.transaction { + if (isNewSession) insertSession(session) + rows.forEach { persist(it, session) } + } + if (isNewSession) sessionId = session + onBatchFlushed?.invoke(rows.size) + } catch (t: Throwable) { + // Reset the session in case its row rolled back with this transaction. + if (isNewSession) sessionId = null + // ponytail: println is the whole story — no logging facility exists; route through a real one if it ever gains one. + println("Sharingan persistence: dropped a batch of ${snapshot.size} events ($t)") + } + } + + private fun insertSession(id: String) { + database.sharinganDatabaseQueries.insertSession( + id = id, + started_at = nowMillis(), + app_id = null, + build = null, + os = null, + device_model = null, + ) + } + + private fun persist( + row: EventRow, + session: String, + ) { + database.sharinganDatabaseQueries.insertEvent( + id = "$session-${row.rawId}", + session_id = session, + timestamp = row.timestampMillis, + type = row.type, + is_failure = if (row.isFailure) 1L else 0L, + host_or_topic = row.hostOrTopic, + payload_json = row.payloadJson, + ) + } + + internal companion object { + internal const val DEFAULT_BATCH_SIZE: Int = 50 + internal const val DEFAULT_FLUSH_INTERVAL_MILLIS: Long = 250L + internal const val CHANNEL_CAPACITY: Int = 1024 + } +} + +// DROP_OLDEST keeps the crash-tail — the newest events matter most here. +internal fun newEventChannel(capacity: Int): Channel = Channel(capacity = capacity, onBufferOverflow = BufferOverflow.DROP_OLDEST) + +// Writes and clears travel on one channel, so the single flusher applies DB mutations in submission order. +private sealed interface Command { + data class Record( + val event: T, + ) : Command + + data object ClearAll : Command +} + +// UUID, not timestamp+Random: the latter collides on Kotlin/Native under rapid calls. +@OptIn(ExperimentalUuidApi::class) +private fun newSessionId(): String = "session-${Uuid.random()}" diff --git a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq b/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq similarity index 81% rename from sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq rename to sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq index a20dcff..9cda940 100644 --- a/sharingan/src/commonMain/sqldelight/dev/sharingan/persistence/SharinganDatabase.sq +++ b/sharingan-db/src/commonMain/sqldelight/dev/sharingan/db/SharinganDatabase.sq @@ -33,3 +33,15 @@ SELECT * FROM session WHERE id = ?; selectEventsForSession: SELECT * FROM event WHERE session_id = ? ORDER BY timestamp ASC; + +selectAllSessions: +SELECT * FROM session ORDER BY started_at ASC; + +selectAllEvents: +SELECT * FROM event ORDER BY timestamp ASC; + +deleteSession: +DELETE FROM session WHERE id = ?; + +deleteAllEvents: +DELETE FROM event; diff --git a/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt new file mode 100644 index 0000000..4d66a1e --- /dev/null +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/PersistenceControllerTest.kt @@ -0,0 +1,407 @@ +package dev.sharingan.db + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +internal class PersistenceControllerTest { + private fun event(id: String): EventRow = + EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"method":"GET"}""", + ) + + @Test + fun `Given a burst within the channel capacity When flushed Then every event is persisted`() = + runBlocking { + val total = 500 + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) + + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() + + repeat(total) { i -> controller.submit(event("e$i")) } + + withTimeout(10_000) { done.await() } + assertEquals(total, flushed) + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(total, rows.size, "all $total events should reach the DB") + assertEquals(total, rows.map { it.id }.toSet().size, "persisted event ids must be unique") + + controller.close() + } + + @Test + fun `Given a burst beyond the channel capacity When flushed Then the newest events survive`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 4, + flushIntervalMillis = 60_000, + channelCapacity = 4, + ) + + fun row(id: String) = + EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) + + // Submit before start() so the flusher cannot drain mid-burst; DROP_OLDEST must retain the newest 4. + repeat(10) { i -> controller.submit(row("e$i")) } + + val done = CompletableDeferred() + controller.onBatchFlushed = { done.complete(Unit) } + controller.start() + + withTimeout(10_000) { done.await() } + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(4, rows.size, "channel should keep only the newest 4 events") + assertEquals( + setOf("""{"id":"e6"}""", """{"id":"e7"}""", """{"id":"e8"}""", """{"id":"e9"}"""), + rows.map { it.payload_json }.toSet(), + ) + + controller.close() + } + + @Test + fun `Given a toRow mapper that throws on one event When flushed Then the flusher survives and other events persist`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { row -> + if (row.rawId == "boom") error("mapper failure") + row + }, + driver = driver, + batchSize = 1, + flushIntervalMillis = 60_000, + ) + controller.start() + + fun row(id: String) = + EventRow( + rawId = id, + timestampMillis = 0L, + type = "HTTP", + isFailure = false, + hostOrTopic = "api.example.com", + payloadJson = """{"id":"$id"}""", + ) + + controller.submit(row("before")) + controller.submit(row("boom")) + controller.submit(row("after")) + + controller.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(setOf("""{"id":"before"}""", """{"id":"after"}"""), rows.map { it.payload_json }.toSet()) + + controller.close() + } + + @Test + fun `Given many events When flushed Then they are written in batches not one per event`() = + runBlocking { + val total = 200 + val batchSize = 50 + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = batchSize, + flushIntervalMillis = 60_000, + ) + + val batches = mutableListOf() + val done = CompletableDeferred() + var flushed = 0 + controller.onBatchFlushed = { size -> + batches += size + flushed += size + if (flushed >= total) done.complete(Unit) + } + controller.start() + + repeat(total) { i -> controller.submit(event("e$i")) } + + withTimeout(10_000) { done.await() } + assertEquals(total, batches.sum()) + assertTrue(batches.size < total, "expected batching, got ${batches.size} writes for $total events") + assertTrue(batches.all { it <= batchSize }, "no batch should exceed $batchSize, got $batches") + + controller.close() + } + + @Test + fun `Given two sessions When events reuse a raw id Then persisted ids do not collide`() = + runBlocking { + val driver = createTestDriver() + + val controller1 = PersistenceController(toRow = { it }, driver = driver) + val done1 = CompletableDeferred() + controller1.onBatchFlushed = { done1.complete(Unit) } + controller1.start() + controller1.submit(event("http-1")) + withTimeout(10_000) { done1.await() } + controller1.stop() + + val controller2 = PersistenceController(toRow = { it }, driver = driver) + val done2 = CompletableDeferred() + controller2.onBatchFlushed = { done2.complete(Unit) } + controller2.start() + controller2.submit(event("http-1")) + withTimeout(10_000) { done2.await() } + controller2.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + assertEquals(2, rows.map { it.id }.toSet().size, "cross-session event ids must be globally unique") + + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(2, sessions.size) + + controller1.close() + controller2.close() + } + + @Test + fun `Given a pending batch When clear is called Then no events are resurrected`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + // Short deadline so flushed-1 lands; pending events queue behind ClearAll well before the next deadline. + flushIntervalMillis = 100, + ) + val firstFlush = CompletableDeferred() + controller.onBatchFlushed = { firstFlush.complete(Unit) } + controller.start() + + controller.submit(event("flushed-1")) + withTimeout(10_000) { firstFlush.await() } + + // Queued but never flushed: under the batch size and far from the deadline. + controller.submit(event("pending-1")) + controller.submit(event("pending-2")) + + val cleared = CompletableDeferred() + controller.onCleared = { cleared.complete(Unit) } + controller.clear() + withTimeout(10_000) { cleared.await() } + + // Drain the flusher: a resurrection would commit the pending batch now. + controller.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(0, rows.size, "clear must delete persisted rows and discard the pending batch") + + controller.close() + } + + @Test + fun `Given a bounded channel When filled beyond capacity Then oldest are dropped and newest survive`() = + runBlocking { + val channel = newEventChannel(capacity = 4) + (1..10).map { event("e$it") }.forEach { channel.trySend(it) } + channel.close() + + val received = mutableListOf() + for (e in channel) received += e + + assertEquals(listOf("e7", "e8", "e9", "e10"), received.map { it.rawId }) + } + + @Test + fun `Given stop is called twice Then it is idempotent and does not crash`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) + controller.start() + repeat(10) { i -> controller.submit(event("e$i")) } + + controller.stop() + controller.stop() + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(10, rows.size) + + controller.close() + } + + @Test + fun `Given start is called twice without stop Then only one flusher runs and every event is persisted`() = + runBlocking { + val total = 5 + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = total, + flushIntervalMillis = 60_000, + ) + + val flushed = CompletableDeferred() + controller.onBatchFlushed = { flushed.complete(Unit) } + + controller.start() + controller.start() + + repeat(total) { i -> controller.submit(event("e$i")) } + + withTimeout(10_000) { flushed.await() } + + assertEquals(1, controller.flusherStartCount(), "only one flusher coroutine must start") + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(total, rows.size, "no event may be lost to a cancelled loser flusher") + + val sessions = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllSessions().executeAsList() + assertEquals(1, sessions.size, "only one flusher/session must be created") + + controller.close() + } + + @Test + fun `Given stop has been called When start is called again Then it throws IllegalStateException`() = + runBlocking { + val driver = createTestDriver() + val controller = PersistenceController(toRow = { it }, driver = driver) + controller.start() + controller.submit(event("a")) + controller.stop() + + assertFailsWith { + controller.start() + } + + controller.close() + } + + @Test + fun `Given buffered events When stopped Then the pending batch is drained before close`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 100, + flushIntervalMillis = 60_000, + ) + val batches = mutableListOf() + controller.onBatchFlushed = { size -> batches += size } + controller.start() + + repeat(30) { i -> controller.submit(event("e$i")) } + + controller.stop() + + assertEquals(30, batches.sum(), "stop() must flush the in-flight batch") + + controller.close() + } + + @Test + fun `Given events under the batch size When the deadline passes Then a deadline flush lands`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) + val flushed = CompletableDeferred() + controller.onBatchFlushed = { flushed.complete(Unit) } + controller.start() + + controller.submit(event("a")) + controller.submit(event("b")) + + withTimeout(10_000) { flushed.await() } + + val rows = SharinganDatabase(driver).sharinganDatabaseQueries.selectAllEvents().executeAsList() + assertEquals(2, rows.size) + + controller.close() + } + + @Test + fun `Given a slow steady stream When events arrive under the batch size Then a flush lands within the interval`() = + runBlocking { + val driver = createTestDriver() + val controller = + PersistenceController( + toRow = { it }, + driver = driver, + batchSize = 50, + flushIntervalMillis = 250, + ) + + var firstFlushAt = 0L + var firstBatchSize = 0 + val flushed = CompletableDeferred() + controller.onBatchFlushed = { size -> + if (firstFlushAt == 0L) { + firstBatchSize = size + firstFlushAt = nowMillis() + flushed.complete(Unit) + } + } + controller.start() + + val startedAt = nowMillis() + repeat(10) { i -> + controller.submit(event("e$i")) + delay(100) + } + + withTimeout(10_000) { flushed.await() } + val elapsed = firstFlushAt - startedAt + assertTrue(firstBatchSize < 10, "deadline flush should fire mid-stream, got a batch of $firstBatchSize") + assertTrue(elapsed < 600, "expected flush within interval, took $elapsed ms") + + controller.close() + } +} diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt similarity index 60% rename from sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt rename to sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt index 3dfe7dd..258ae1d 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/SharinganDatabaseRoundTripTest.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import kotlin.test.Test import kotlin.test.assertEquals @@ -45,4 +45,41 @@ internal class SharinganDatabaseRoundTripTest { assertEquals("api.example.com", event.host_or_topic) assertEquals("""{"method":"GET"}""", event.payload_json) } + + @Test + fun `Given events for a session When the session is deleted Then its events cascade away`() { + val db = SharinganDatabase(createTestDriver()) + + db.sharinganDatabaseQueries.insertSession( + id = "session-1", + started_at = 1_700_000_000_000L, + app_id = null, + build = null, + os = null, + device_model = null, + ) + repeat(3) { index -> + db.sharinganDatabaseQueries.insertEvent( + id = "event-$index", + session_id = "session-1", + timestamp = 1_700_000_001_000L + index, + type = "HTTP", + is_failure = 0L, + host_or_topic = "api.example.com", + payload_json = """{"n":$index}""", + ) + } + + assertEquals( + 3, + db.sharinganDatabaseQueries + .selectAllEvents() + .executeAsList() + .size, + ) + + db.sharinganDatabaseQueries.deleteSession("session-1") + + assertEquals(emptyList(), db.sharinganDatabaseQueries.selectAllEvents().executeAsList()) + } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt similarity index 73% rename from sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt rename to sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt index e794801..97023ba 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/TestSqlDriver.kt +++ b/sharingan-db/src/commonTest/kotlin/dev/sharingan/db/TestSqlDriver.kt @@ -1,4 +1,4 @@ -package dev.sharingan.persistence +package dev.sharingan.db import app.cash.sqldelight.db.SqlDriver diff --git a/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt new file mode 100644 index 0000000..0d4d581 --- /dev/null +++ b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/DriverFactory.ios.kt @@ -0,0 +1,13 @@ +package dev.sharingan.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver + +internal actual class DriverFactory actual constructor() { + actual fun create(): SqlDriver = + NativeSqliteDriver( + schema = SharinganDatabase.Schema, + name = "sharingan.db", + onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(foreignKeyConstraints = true)) }, + ) +} diff --git a/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt new file mode 100644 index 0000000..135ee2e --- /dev/null +++ b/sharingan-db/src/iosMain/kotlin/dev/sharingan/db/Now.ios.kt @@ -0,0 +1,6 @@ +package dev.sharingan.db + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +internal actual fun nowMillis(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt b/sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt new file mode 100644 index 0000000..4f3adc7 --- /dev/null +++ b/sharingan-db/src/iosTest/kotlin/dev/sharingan/db/TestSqlDriver.ios.kt @@ -0,0 +1,19 @@ +package dev.sharingan.db + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +internal actual fun createTestDriver(): SqlDriver = + NativeSqliteDriver( + schema = SharinganDatabase.Schema, + name = "sharingan-test-${Uuid.random()}.db", + onConfiguration = { + it.copy( + inMemory = true, + extendedConfig = it.extendedConfig.copy(foreignKeyConstraints = true), + ) + }, + ) diff --git a/sharingan/api/sharingan.klib.api b/sharingan/api/sharingan.klib.api index ddc83fe..d312b33 100644 --- a/sharingan/api/sharingan.klib.api +++ b/sharingan/api/sharingan.klib.api @@ -284,6 +284,11 @@ final object dev.sharingan/SharinganExport { // dev.sharingan/SharinganExport|nu final fun summary(kotlin.collections/List): kotlin/String // dev.sharingan/SharinganExport.summary|summary(kotlin.collections.List){}[0] } +final val dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop|#static{}dev_sharingan_internal_BleDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop|#static{}dev_sharingan_internal_HeaderDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop|#static{}dev_sharingan_internal_HttpDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop|#static{}dev_sharingan_internal_MqttDto_$serializer$stableprop[0] +final val dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop // dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop|#static{}dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop[0] final val dev.sharingan.ktor/SharinganKtor // dev.sharingan.ktor/SharinganKtor|{}SharinganKtor[0] final fun (): io.ktor.client.plugins.api/ClientPlugin // dev.sharingan.ktor/SharinganKtor.|(){}[0] final val dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop // dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop|#static{}dev_sharingan_ktor_SharinganKtorConfig$stableprop[0] @@ -298,6 +303,11 @@ final val dev.sharingan/dev_sharingan_SharinganExport$stableprop // dev.sharinga final val dev.sharingan/dev_sharingan_SharinganStore$stableprop // dev.sharingan/dev_sharingan_SharinganStore$stableprop|#static{}dev_sharingan_SharinganStore$stableprop[0] final val dev.sharingan/dev_sharingan_TimingPhase$stableprop // dev.sharingan/dev_sharingan_TimingPhase$stableprop|#static{}dev_sharingan_TimingPhase$stableprop[0] +final fun dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_BleDto_$serializer$stableprop_getter|dev_sharingan_internal_BleDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter|dev_sharingan_internal_HeaderDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_HttpDto_$serializer$stableprop_getter|dev_sharingan_internal_HttpDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_MqttDto_$serializer$stableprop_getter|dev_sharingan_internal_MqttDto_$serializer$stableprop_getter(){}[0] +final fun dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter(): kotlin/Int // dev.sharingan.internal/dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter|dev_sharingan_internal_TimingPhaseDto_$serializer$stableprop_getter(){}[0] final fun dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter(): kotlin/Int // dev.sharingan.ktor/dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter|dev_sharingan_ktor_SharinganKtorConfig$stableprop_getter(){}[0] final fun dev.sharingan.ui/SharinganScreen(androidx.compose.ui/Modifier?, kotlin/Boolean, dev.sharingan/SharinganStore?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // dev.sharingan.ui/SharinganScreen|SharinganScreen(androidx.compose.ui.Modifier?;kotlin.Boolean;dev.sharingan.SharinganStore?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun dev.sharingan/SharinganViewController(): platform.UIKit/UIViewController // dev.sharingan/SharinganViewController|SharinganViewController(){}[0] diff --git a/sharingan/build.gradle.kts b/sharingan/build.gradle.kts index 2b8e57a..50d9b72 100644 --- a/sharingan/build.gradle.kts +++ b/sharingan/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) - alias(libs.plugins.sqldelight) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.mavenPublish) alias(libs.plugins.ktlint) @@ -17,14 +16,6 @@ plugins { group = "io.github.mibrahimdev" version = libs.versions.sharingan.get() -sqldelight { - databases { - create("SharinganDatabase") { - packageName.set("dev.sharingan.persistence") - } - } -} - kotlin { explicitApi() @@ -58,6 +49,8 @@ kotlin { implementation(libs.ktor.client.core) // Flight-recorder persistence (issue #27 / #49): DTO JSON codec. implementation(libs.kotlinx.serialization.json) + // On-device SQLDelight database; implementation-scoped so its generated types are not exported to consumers. + implementation(project(":sharingan-db")) implementation(compose.runtime) implementation(compose.foundation) @@ -72,16 +65,12 @@ kotlin { } androidMain.dependencies { implementation(libs.androidx.activity.compose) - implementation(libs.sqldelight.android.driver) } iosMain.dependencies { - implementation(libs.sqldelight.native.driver) } androidUnitTest.dependencies { - implementation(libs.sqldelight.sqlite.driver) } iosTest.dependencies { - implementation(libs.sqldelight.native.driver) } // On-device UI tests (CMP test API): ./gradlew :sharingan:connectedDebugAndroidTest androidInstrumentedTest.dependencies { diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt index 5473e27..d81f20f 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt @@ -6,6 +6,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import android.net.Uri +import dev.sharingan.db.SharinganDbContext /** * Holds the application context Sharingan needs for the clipboard, the share @@ -27,7 +28,9 @@ public class SharinganInitProvider : ContentProvider() { override fun onCreate(): Boolean { val appContext = context?.applicationContext ?: return true SharinganAndroid.appContext = appContext + SharinganDbContext.install(appContext) CaptureNotification.start(appContext) + Persistence.start() return true } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt deleted file mode 100644 index e8d1fc6..0000000 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.sharingan.persistence - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.android.AndroidSqliteDriver -import dev.sharingan.internal.SharinganAndroid - -internal actual class DriverFactory { - actual fun create(): SqlDriver { - val context = - SharinganAndroid.appContext - ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") - return AndroidSqliteDriver(SharinganDatabase.Schema, context, "sharingan.db") - } -} diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 5eca7dd..3f9f317 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlin.concurrent.Volatile /** * In-memory ring buffer of captured [SharinganEvent]s. @@ -22,6 +23,25 @@ public class SharinganStore( private val _events = MutableStateFlow>(emptyList()) private val _isRecording = MutableStateFlow(true) + /** + * Internal persistence seam: invoked with each accepted event after the + * CAS append. `null` unless the flight-recorder persistence is wired up + * (see `dev.sharingan.db.PersistenceController`). Internal — never + * part of the public API, so :sharingan-noop mirrors nothing here. + */ + @Volatile + internal var onRecord: ((SharinganEvent) -> Unit)? = null + + /** + * Internal persistence seam: invoked from [clear] so the persistence layer + * can drop the mirrored rows on disk too. `null` unless the flight-recorder + * persistence is wired up (see `dev.sharingan.db.PersistenceController`). + * Internal — never part of the public API, so :sharingan-noop mirrors + * nothing here. + */ + @Volatile + internal var onClear: (() -> Unit)? = null + /** All retained events, oldest first. */ public val events: StateFlow> = _events.asStateFlow() @@ -39,6 +59,7 @@ public class SharinganStore( appended } } + onRecord?.invoke(event) } /** Pauses (`false`) or resumes (`true`) capture. Paused events are dropped, not queued. */ @@ -49,6 +70,7 @@ public class SharinganStore( /** Removes all retained events. */ public fun clear() { _events.value = emptyList() + onClear?.invoke() } public companion object { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt new file mode 100644 index 0000000..f0b0dec --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/EventDto.kt @@ -0,0 +1,125 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.HttpEvent +import dev.sharingan.MqttEvent +import dev.sharingan.SharinganEvent +import kotlinx.serialization.Serializable + +/** + * Serializable mirror of [SharinganEvent], stored as the `event.payload_json` + * blob. A separate internal type because the #15 ABI freeze forbids adding + * `@Serializable` to the public events. + * + * Encode-only ([fromEvent]). Every field is mirrored and enums carried by name + * so a decoder can be added without a format change. + */ +@Serializable +internal sealed interface EventDto { + val id: String + val timestampMillis: Long + val error: String? + + companion object { + fun fromEvent(event: SharinganEvent): EventDto = + when (event) { + is HttpEvent -> + HttpDto( + id = event.id, + timestampMillis = event.timestampMillis, + method = event.method, + url = event.url, + statusCode = event.statusCode, + durationMillis = event.durationMillis, + requestHeaders = event.requestHeaders.map { (name, value) -> HeaderDto(name, value) }, + responseHeaders = event.responseHeaders.map { (name, value) -> HeaderDto(name, value) }, + requestBody = event.requestBody, + responseBody = event.responseBody, + contentType = event.contentType, + responseSizeBytes = event.responseSizeBytes, + timing = event.timing.map { TimingPhaseDto(it.label, it.millis) }, + error = event.error, + ) + is MqttEvent -> + MqttDto( + id = event.id, + timestampMillis = event.timestampMillis, + direction = event.direction.name, + topic = event.topic, + qos = event.qos, + retained = event.retained, + payload = event.payload, + payloadSizeBytes = event.payloadSizeBytes, + error = event.error, + ) + is BleEvent -> + BleDto( + id = event.id, + timestampMillis = event.timestampMillis, + operation = event.operation.name, + device = event.device, + characteristic = event.characteristic, + uuid = event.uuid, + payload = event.payload, + sizeBytes = event.sizeBytes, + error = event.error, + ) + } + } +} + +@Serializable +internal data class HeaderDto( + val name: String, + val value: String, +) + +@Serializable +internal data class TimingPhaseDto( + val label: String, + val millis: Long, +) + +@Serializable +internal data class HttpDto( + override val id: String, + override val timestampMillis: Long, + val method: String, + val url: String, + val statusCode: Int? = null, + val durationMillis: Long? = null, + val requestHeaders: List = emptyList(), + val responseHeaders: List = emptyList(), + val requestBody: String? = null, + val responseBody: String? = null, + val contentType: String? = null, + val responseSizeBytes: Long? = null, + val timing: List = emptyList(), + override val error: String? = null, +) : EventDto + +@Serializable +internal data class MqttDto( + override val id: String, + override val timestampMillis: Long, + val direction: String, + val topic: String, + val qos: Int = 0, + val retained: Boolean = false, + val payload: String? = null, + val payloadSizeBytes: Long? = null, + override val error: String? = null, +) : EventDto + +@Serializable +internal data class BleDto( + override val id: String, + override val timestampMillis: Long, + val operation: String, + val device: String, + val characteristic: String? = null, + val uuid: String? = null, + val payload: String? = null, + val sizeBytes: Long? = null, + override val error: String? = null, +) : EventDto diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt new file mode 100644 index 0000000..740bf2a --- /dev/null +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/Persistence.kt @@ -0,0 +1,87 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.HttpEvent +import dev.sharingan.MqttEvent +import dev.sharingan.Sharingan +import dev.sharingan.SharinganEvent +import dev.sharingan.SharinganStore +import dev.sharingan.db.EventRow +import dev.sharingan.db.PersistenceController +import kotlinx.serialization.json.Json +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Starts persistence once per process. The only caller is Android's + * manifest-merged ContentProvider — there is no Kotlin call site to grep. + * [stop] tears the wiring down so a new lifecycle can start again. + * + * ponytail: not unit-tested. [start] builds a real driver, and on Android + * DriverFactory.create() needs the ContentProvider-installed Context, which a + * JVM unit test has none of. Covering this would mean either Robolectric or a + * controller-injection seam; neither is worth it for straight-line wiring. + * The persistence behaviour it wires is tested in :sharingan-db. + */ +@OptIn(ExperimentalAtomicApi::class) +internal object Persistence { + private val started = AtomicBoolean(false) + private var controller: PersistenceController? = null + private var store: SharinganStore? = null + + fun start(store: SharinganStore = Sharingan.store) { + if (!started.compareAndSet(false, true)) return + val controller = PersistenceController(::toRow) + // Seams before flusher: start() is lazy so the old order worked by luck; this encodes the dependency. + store.onRecord = { event -> controller.submit(event) } + store.onClear = { controller.clear() } + this.store = store + this.controller = controller + controller.start() + } + + /** Unwires the seams and closes the controller; [start] may run again afterwards. */ + suspend fun stop() { + val controller = controller ?: return + store?.onRecord = null + store?.onClear = null + store = null + this.controller = null + controller.close() + started.store(false) + } +} + +internal val json = Json { encodeDefaults = true } + +// Bodies stay off disk (design default persistBodies = false); EventDto keeps the fields for the slice-5 opt-in. +internal fun toRow(event: SharinganEvent): EventRow = + EventRow( + rawId = event.id, + timestampMillis = event.timestampMillis, + type = event.typeName(), + isFailure = event.isFailure, + hostOrTopic = event.hostOrTopic(), + payloadJson = json.encodeToString(EventDto.serializer(), EventDto.fromEvent(event).withoutBodies()), + ) + +private fun EventDto.withoutBodies(): EventDto = + when (this) { + is HttpDto -> copy(requestBody = null, responseBody = null) + is MqttDto -> copy(payload = null) + is BleDto -> copy(payload = null) + } + +private fun SharinganEvent.typeName(): String = + when (this) { + is HttpEvent -> "HTTP" + is MqttEvent -> "MQTT" + is BleEvent -> "BLE" + } + +private fun SharinganEvent.hostOrTopic(): String? = + when (this) { + is HttpEvent -> host + is MqttEvent -> topic + is BleEvent -> device + } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index 008f130..2810fa7 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue internal class SharinganStoreTest { @@ -90,6 +91,19 @@ internal class SharinganStoreTest { assertEquals(1, events.count { it is BleEvent }) } + @Test + fun `Given an onClear seam When the store is cleared Then the seam is invoked`() { + val store = SharinganStore(capacity = 10) + var cleared = 0 + store.onClear = { cleared++ } + + store.record(event("a")) + store.clear() + + assertEquals(1, cleared) + assertTrue(store.events.value.isEmpty()) + } + @Test fun `Given many producers recording concurrently When all complete Then no event is lost or duplicated`() = runTest { @@ -119,4 +133,53 @@ internal class SharinganStoreTest { } assertEquals(expected, ids.toSet()) } + + @Test + fun `When persistence is off Then record keeps its behavior and onRecord stays null`() { + val store = SharinganStore(capacity = 10) + assertNull(store.onRecord) + store.record(event("a")) + store.record(event("b")) + assertEquals(listOf("a", "b"), store.events.value.map { it.id }) + assertNull(store.onRecord) + } + + @Test + fun `Given an onRecord seam When events are recorded Then the seam receives each event`() { + val store = SharinganStore(capacity = 10) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + + store.record(event("a")) + store.record(event("b")) + + assertEquals(listOf("a", "b"), store.events.value.map { it.id }) + assertEquals(listOf("a", "b"), forwarded) + } + + @Test + fun `Given an onRecord seam When recording is paused Then the seam receives nothing`() { + val store = SharinganStore(capacity = 10) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + store.setRecording(false) + + store.record(event("a")) + store.record(event("b")) + + assertTrue(store.events.value.isEmpty()) + assertTrue(forwarded.isEmpty()) + } + + @Test + fun `Given an onRecord seam When the buffer evicts an event Then the evicted event was still forwarded`() { + val store = SharinganStore(capacity = 2) + val forwarded = mutableListOf() + store.onRecord = { forwarded += it.id } + + listOf("a", "b", "c").forEach { store.record(event(it)) } + + assertEquals(listOf("b", "c"), store.events.value.map { it.id }) + assertEquals(listOf("a", "b", "c"), forwarded) + } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt new file mode 100644 index 0000000..33cea1f --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/EventDtoTest.kt @@ -0,0 +1,129 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.BleOperation +import dev.sharingan.HttpEvent +import dev.sharingan.HttpLogger +import dev.sharingan.MqttDirection +import dev.sharingan.MqttEvent +import kotlin.test.Test +import kotlin.test.assertTrue + +internal class EventDtoTest { + @Test + fun `Given an HTTP event When encoded Then method url status and redacted header are present`() { + val event = + HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + durationMillis = 42L, + requestHeaders = listOf("Authorization" to HttpLogger.REDACTED_VALUE, "Content-Type" to "application/json"), + responseHeaders = listOf("X-Request-Id" to "abc"), + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + contentType = "application/json", + responseSizeBytes = 128L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"method\":\"POST\"" in encoded) + assertTrue("\"url\":\"https://api.example.com/v1/users\"" in encoded) + assertTrue("\"statusCode\":201" in encoded) + assertTrue("\"Authorization\"" in encoded) + assertTrue("\"value\":\"${HttpLogger.REDACTED_VALUE}\"" in encoded) + assertTrue("\"Content-Type\"" in encoded) + assertTrue("\"application/json\"" in encoded) + } + + @Test + fun `Given an MQTT publish event When encoded Then topic direction qos and retained flag are present`() { + val event = + MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + qos = 1, + retained = true, + payload = """{"online":true}""", + payloadSizeBytes = 64L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"direction\":\"PUBLISH\"" in encoded) + assertTrue("\"topic\":\"devices/001/status\"" in encoded) + assertTrue("\"qos\":1" in encoded) + assertTrue("\"retained\":true" in encoded) + assertTrue("\"payload\":\"{\\\"online\\\":true}\"" in encoded) + } + + @Test + fun `Given an MQTT receive event When encoded Then direction is RECEIVE`() { + val event = + MqttEvent( + id = "mqtt-2", + timestampMillis = 1_700_000_000_001L, + direction = MqttDirection.RECEIVE, + topic = "alerts/critical", + qos = 2, + retained = false, + payload = "alert", + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"direction\":\"RECEIVE\"" in encoded) + assertTrue("\"topic\":\"alerts/critical\"" in encoded) + assertTrue("\"qos\":2" in encoded) + } + + @Test + fun `Given a BLE read event When encoded Then operation device and characteristic are present`() { + val event = + BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + characteristic = "Heart Rate Measurement", + uuid = "00002a37-0000-1000-8000-00805f9b34fb", + payload = "{\"bpm\":72}", + sizeBytes = 32L, + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"operation\":\"READ\"" in encoded) + assertTrue("\"device\":\"HR-Monitor-A1\"" in encoded) + assertTrue("\"characteristic\":\"Heart Rate Measurement\"" in encoded) + assertTrue("\"uuid\":\"00002a37-0000-1000-8000-00805f9b34fb\"" in encoded) + } + + @Test + fun `Given a BLE error event When encoded Then error flag is present`() { + val event = + BleEvent( + id = "ble-2", + timestampMillis = 1_700_000_000_001L, + operation = BleOperation.CONNECT, + device = "Scale-B2", + error = "GATT 133", + ) + + val dto = EventDto.fromEvent(event) + val encoded = json.encodeToString(EventDto.serializer(), dto) + + assertTrue("\"operation\":\"CONNECT\"" in encoded) + assertTrue("\"device\":\"Scale-B2\"" in encoded) + assertTrue("\"error\":\"GATT 133\"" in encoded) + } +} diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt new file mode 100644 index 0000000..4fb7c18 --- /dev/null +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/PersistenceToRowTest.kt @@ -0,0 +1,70 @@ +package dev.sharingan.internal + +import dev.sharingan.BleEvent +import dev.sharingan.BleOperation +import dev.sharingan.HttpEvent +import dev.sharingan.MqttDirection +import dev.sharingan.MqttEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PersistenceToRowTest { + @Test + fun `Given an HTTP event with bodies When it becomes a row Then the bodies are not persisted`() { + val event = + HttpEvent( + id = "http-1", + timestampMillis = 1_700_000_000_000L, + method = "POST", + url = "https://api.example.com/v1/users", + statusCode = 201, + requestBody = """{"name":"Ada"}""", + responseBody = """{"id":1}""", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is HttpDto) + assertNull(dto.requestBody, "request bodies must stay off disk (persistBodies = false)") + assertNull(dto.responseBody, "response bodies must stay off disk (persistBodies = false)") + assertEquals("POST", dto.method) + } + + @Test + fun `Given an MQTT event with a payload When it becomes a row Then the payload is not persisted`() { + val event = + MqttEvent( + id = "mqtt-1", + timestampMillis = 1_700_000_000_000L, + direction = MqttDirection.PUBLISH, + topic = "devices/001/status", + payload = """{"online":true}""", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is MqttDto) + assertNull(dto.payload, "MQTT payloads must stay off disk (persistBodies = false)") + assertEquals("devices/001/status", dto.topic) + } + + @Test + fun `Given a BLE event with a payload When it becomes a row Then the payload is not persisted`() { + val event = + BleEvent( + id = "ble-1", + timestampMillis = 1_700_000_000_000L, + operation = BleOperation.READ, + device = "HR-Monitor-A1", + payload = "{\"bpm\":72}", + ) + + val dto = json.decodeFromString(EventDto.serializer(), toRow(event).payloadJson) + + assertTrue(dto is BleDto) + assertNull(dto.payload, "BLE payloads must stay off disk (persistBodies = false)") + assertEquals("HR-Monitor-A1", dto.device) + } +} diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt deleted file mode 100644 index 880a0f9..0000000 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.sharingan.persistence - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver - -internal actual class DriverFactory { - actual fun create(): SqlDriver = NativeSqliteDriver(SharinganDatabase.Schema, "sharingan.db") -} diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt b/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt deleted file mode 100644 index 966eadb..0000000 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/persistence/TestSqlDriver.ios.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.sharingan.persistence - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver - -internal actual fun createTestDriver(): SqlDriver = - NativeSqliteDriver( - schema = SharinganDatabase.Schema, - name = "sharingan-test.db", - onConfiguration = { it.copy(inMemory = true) }, - )