chore(frontend): remove custom Webpack sqlite-wasm integration plugins and cleanup config

- Deleted `dummy.js` and its usage for sqlite-wasm integration as custom Webpack adjustments are no longer necessary.
- Removed outdated Webpack configuration files: `ignore-sqlite-wasm.js`, `ignore-sqlite-wasm-critical-dependency.js`, and `sqljs-fix.js`.
- Introduced `sqlite-config.js` for simplified and streamlined sqlite-wasm and Skiko Webpack configuration.
- Minor code formatting adjustments across frontend modules for improved consistency.
This commit is contained in:
2026-01-26 20:37:23 +01:00
parent 29ad73b508
commit 3e587381ed
30 changed files with 1535 additions and 941 deletions
@@ -0,0 +1,649 @@
Hier ist der Quellcode des Berichts im Markdown-Format:
# Architektonische Resilienz in verteilten Systemen: Ein umfassender Leitfaden zur Implementierung von Offline-First Kotlin Multiplatform Architekturen mit SQLDelight
## Zusammenfassung
Die Softwareentwicklungslandschaft des Jahres 2026, geprägt durch die Veröffentlichung von Kotlin 2.3.0 und Gradle 9.1.0, bietet Entwicklern beispiellose Möglichkeiten zur Vereinheitlichung komplexer Geschäftslogik über Plattformgrenzen hinweg. Dieser Forschungsbericht analysiert detailliert die architektonischen Muster, Implementierungsstrategien und zugrundeliegenden Mechanismen, die für den Aufbau einer robusten, asynchronen Offline-First-Anwendung erforderlich sind. Der Fokus liegt hierbei auf der Integration von SQLDelight in einer Kotlin Multiplatform (KMP) Umgebung, die sowohl Desktop (JVM) als auch Web (Kotlin/JS) Ziele bedient, eingebettet in eine Mikro-Frontend-Architektur.
Ein zentraler Schwerpunkt dieser Arbeit ist die Überbrückung der Dichotomie zwischen der synchronen Natur klassischer JVM-Datenbanktreiber und der inhärent asynchronen, Event-Loop-basierten Umgebung des modernen Web (insbesondere unter Nutzung von Web Workern und OPFS). Darüber hinaus wird die fortgeschrittene Integration von Persistenzschichten in einem Mikro-Frontend-Ökosystem untersucht, um sicherzustellen, dass eine einzige Quelle der Wahrheit („Single Source of Truth“) über unabhängig bereitgestellte Frontend-Einheiten hinweg konsistent bleibt.
## 1. Einleitung und technologischer Kontext (2026)
### 1.1 Die Evolution von Kotlin Multiplatform
Mit der Veröffentlichung von Kotlin 2.3.0 im Dezember 2025 hat sich das Ökosystem von einer experimentellen Technologie zu einem stabilen Standard für Enterprise-Architekturen entwickelt. Während frühere Versionen oft mit Inkonsistenzen zwischen den Compilern (JVM vs. JS/Native) zu kämpfen hatten, bietet der K2-Compiler in Version 2.3.0 eine vereinheitlichte Frontend-IR (Intermediate Representation), die eine robustere statische Analyse und performantere Kompilierung ermöglicht. Dies ist entscheidend für komplexe Multi-Modul-Projekte, wie sie in Mikro-Frontend-Architekturen üblich sind.
### 1.2 Gradle 9.1.0: Die Build-Infrastruktur
Gradle 9.1.0, veröffentlicht im September 2025, hat die Art und Weise, wie KMP-Projekte konfiguriert werden, grundlegend verändert. Mit der vollständigen Unterstützung des „Configuration Cache“ und der strikten „Project Isolation“ zwingt es Entwickler zu sauberen Modulgrenzen. Für unser Szenario bedeutet dies, dass die Abhängigkeiten zwischen dem `shared`-Modul (Datenbank) und den konsumierenden Mikro-Frontends explizit und ohne Seiteneffekte definiert werden müssen, um die parallele Ausführung und inkrementelle Kompilierung nicht zu gefährden.
### 1.3 Die Problemstellung: Synchron vs. Asynchron
Die zentrale Herausforderung bei der Entwicklung einer Cross-Platform-Datenbanklösung liegt in den unterschiedlichen I/O-Modellen der Zielplattformen:
* **JVM (Desktop):** Historisch geprägt durch blockierende I/O-Operationen (JDBC). Ein Datenbankaufruf blockiert den Thread, bis das Ergebnis vorliegt.
* **Kotlin/JS (Web):** Basiert auf einem Single-Threaded Event Loop. Blockierende Operationen sind hier verboten, da sie das UI einfrieren würden. Moderne Web-Architekturen lagern datenintensive Aufgaben in Web Worker aus, die ausschließlich über asynchrone Nachrichten (Promises/Futures) kommunizieren.
SQLDelight 2.0+ adressiert dieses Problem mit der Konfiguration `generateAsync = true`. Dieser Bericht wird detailliert darlegen, wie diese Einstellung genutzt werden kann, um eine einheitliche, asynchrone Schnittstelle zu schaffen, die auf der JVM effizient in Coroutinen gekapselt wird, während sie im Web nativ mit dem asynchronen Modell korrespondiert.
## 2. Architektonische Fundamente
### 2.1 Das Offline-First Paradigma
In einer Offline-First-Architektur fungiert die lokale Datenbank nicht als bloßer Cache, sondern als primäre Quelle der Wahrheit. Die Benutzeroberfläche (UI) kommuniziert niemals direkt mit dem Netzwerk.
| Konzept | Traditionelle Architektur | Offline-First Architektur |
| --- | --- | --- |
| **Datenquelle** | Remote API (REST/GraphQL) | Lokale Datenbank (SQLite) |
| **Lesepfad** | UI ruft Netzwerk auf -> Wartet -> Zeigt an | UI beobachtet Datenbank (Flow) -> Zeigt an |
| **Schreibpfad** | UI sendet an API -> Wartet auf OK -> Aktualisiert UI | UI schreibt in DB -> DB emittiert neue Daten -> Sync im Hintergrund |
| **Netzwerkstatus** | Voraussetzung für Funktionalität | Optional; beeinflusst nur Synchronisation |
Dieses Prinzip der „Inversion of Control“ entkoppelt die User Experience von Netzwerklatenz und -verfügbarkeit. In SQLDelight wird dies durch Reactive Extensions realisiert, die SQL-Abfragen als `Flow<T>` exponieren, die sich bei Datenänderungen automatisch aktualisieren.
### 2.2 Mikro-Frontends in Kotlin/JS
Die Mikro-Frontend-Architektur überträgt die Prinzipien von Microservices auf das Frontend: Die Anwendung wird in vertikale, in sich geschlossene Slices (Features) zerlegt, die unabhängig entwickelt und deployt werden können. Im Kontext von Kotlin/JS und einer geteilten Datenbank stellt dies eine besondere Herausforderung dar: **Das Singleton-Problem**.
Wenn Modul A (z.B. „Dashboard“) und Modul B (z.B. „Einstellungen“) jeweils ihre eigene Instanz der Datenbank-Engine initialisieren, kommt es zu Konflikten beim Zugriff auf die physische Speicherdatei (z.B. im Origin Private File System, OPFS). Da SQLite in der Regel nur einen Schreibzugriff gleichzeitig erlaubt (Single Writer Principle), muss die Architektur einen **Shared Core Kernel** definieren ein separat kompiliertes Modul, das die Datenbankinstanz hält und via Webpack Module Federation in die Feature-Module injiziert wird.
## 3. Projekt-Setup und Build-Konfiguration
Das Fundament eines stabilen KMP-Projekts ist eine präzise Gradle-Konfiguration. Wir verwenden einen Version Catalog (`libs.versions.toml`), um Konsistenz über alle Module hinweg zu gewährleisten.
### 3.1 Version Catalog (`gradle/libs.versions.toml`)toml
[versions]
kotlin = "2.3.0"
gradle = "9.1.0"
sqldelight = "2.1.0"
coroutines = "1.10.1" # Hypothetische Version passend zu Kotlin 2.3
ktor = "3.1.0"
koin = "4.0.0"
serialization = "1.8.0"
[libraries]
# SQLDelight
sqldelight-gradle = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" }
sqldelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-driver-webworker = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
# Kotlin
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } # Für Desktop UI
# Web Specifics
npm-sqljs = { module = "sql.js", version = "1.12.0" }
npm-copy-webpack = { module = "copy-webpack-plugin", version = "12.0.0" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
```
### 3.2 Modulstruktur für Mikro-Frontends
Eine saubere Trennung ist essenziell. Wir empfehlen folgende Struktur:
* `:shared-kernel` (KMP): Enthält die Datenbankdefinition (`.sq` Dateien), die generierten Interfaces und die Singleton-Instanziierung der Datenbank.
* `:shared-logic` (KMP): Enthält domänenspezifische Logik, Repositories und ViewModels, die den Kernel nutzen.
* `:desktop-app` (JVM): Der Einstiegspunkt für die Desktop-Anwendung.
* `:web-host` (JS): Die „Shell“-Anwendung, die Mikro-Frontends lädt.
* `:web-feature-a` (JS): Ein eigenständiges Mikro-Frontend.
### 3.3 Konfiguration des Shared-Kernel Moduls
Das `shared-kernel` Modul ist das Herzstück. Hier wird SQLDelight konfiguriert.
**`shared-kernel/build.gradle.kts`**:
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.sqldelight)
}
kotlin {
// 1. JVM Target für Desktop
jvm("desktop")
// 2. JS Target für Web (IR Compiler ist Standard in 2.0+)
js(IR) {
// WICHTIG: Als Library kompilieren für Webpack Federation
binaries.library()
generateTypeScriptDefinitions()
browser {
commonWebpackConfig {
cssSupport {
enabled.set(true)
}
}
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.kotlin.coroutines.core)
implementation(libs.sqldelight.coroutines)
}
}
val desktopMain by getting {
dependencies {
implementation(libs.sqldelight.driver.sqlite)
}
}
val jsMain by getting {
dependencies {
implementation(libs.sqldelight.driver.webworker)
// NPM Abhängigkeiten für SQL.js / Worker
implementation(npm("sql.js", "1.12.0"))
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0"))
implementation(devNpm("copy-webpack-plugin", "12.0.0"))
}
}
}
}
// 3. SQLDelight Konfiguration
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.offlinefirst.db")
// KRITISCH: Aktiviert Suspend-Funktionen in generierten Interfaces
generateAsync.set(true)
// Stellt sicher, dass das Schema für beide Plattformen kompatibel bleibt
schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases"))
verifyMigrations.set(true)
}
}
}
```
Die Einstellung `generateAsync.set(true)` ist der entscheidende Hebel. Ohne diese Einstellung generiert SQLDelight blockierende Funktionen (`executeAsList`). Mit ihr werden `suspend` Funktionen (`awaitAsList`) generiert. Dies ist für das Web-Target zwingend erforderlich, da der Web Worker asynchron antwortet.
## 4. Datenbank-Design und Schema-Management
In einer Offline-First-Architektur muss das Datenbankschema robust genug sein, um Synchronisationsstatus zu verwalten.
**`shared-kernel/src/commonMain/sqldelight/com/example/offlinefirst/db/Task.sq`**:
```sql
-- Tabelle für Aufgaben
CREATE TABLE Task (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
is_completed INTEGER AS Boolean NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- Synchronisations-Metadaten
sync_status TEXT NOT NULL DEFAULT 'SYNCED', -- 'SYNCED', 'DIRTY', 'DELETED'
last_synced_at INTEGER
);
-- Indizes für Performance bei Abfragen
CREATE INDEX idx_task_sync_status ON Task(sync_status);
CREATE INDEX idx_task_created_at ON Task(created_at);
-- Queries (Nutzung von Named Arguments für Kotlin)
selectAll:
SELECT * FROM Task
WHERE sync_status!= 'DELETED'
ORDER BY created_at DESC;
selectDirty:
SELECT * FROM Task
WHERE sync_status = 'DIRTY';
insertOrReplace:
INSERT OR REPLACE INTO Task(id, title, description, is_completed, created_at, updated_at, sync_status, last_synced_at)
VALUES (?,?,?,?,?,?,?,?);
updateCompletion:
UPDATE Task
SET is_completed = :isCompleted,
updated_at = :updatedAt,
sync_status = 'DIRTY'
WHERE id = :id;
markAsSynced:
UPDATE Task
SET sync_status = 'SYNCED',
last_synced_at = :syncedAt
WHERE id = :id;
softDelete:
UPDATE Task
SET sync_status = 'DELETED',
updated_at = :deletedAt
WHERE id = :id;
```
**Analyse:**
Wir verwenden „Soft Deletes“ (Markieren als gelöscht statt physischem Löschen), um sicherzustellen, dass Löschvorgänge auch an den Server synchronisiert werden können. Das Feld `sync_status` steuert die Synchronisationslogik.
## 5. Plattform-Implementierung: Die Treiber-Schicht
Hier liegt die größte Komplexität. Wir müssen eine Brücke zwischen der synchronen JVM-Welt und der asynchronen JS-Welt schlagen.
### 5.1 Abstrakte Fabrik
Im `commonMain` definieren wir eine Schnittstelle zur Erstellung des Treibers.
```kotlin
// shared-kernel/src/commonMain/kotlin/DatabaseFactory.kt
interface DatabaseFactory {
suspend fun createDriver(): SqlDriver
}
// Hilfsfunktion zur Initialisierung
suspend fun createDatabase(factory: DatabaseFactory): AppDatabase {
val driver = factory.createDriver()
// Schema-Erstellung muss ebenfalls asynchron erwartet werden
AppDatabase.Schema.create(driver).await()
return AppDatabase(driver)
}
```
### 5.2 Desktop (JVM) Implementierung: Der asynchrone Wrapper
Der `JdbcSqliteDriver` ist blockierend. Auch wenn SQLDelight `suspend` Interfaces generiert, führt der zugrunde liegende Treiber I/O auf dem aufrufenden Thread aus.
**`shared-kernel/src/desktopMain/kotlin/DesktopDatabaseFactory.kt`**:
```kotlin
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import java.io.File
class DesktopDatabaseFactory(private val dbPath: String) : DatabaseFactory {
override suspend fun createDriver(): SqlDriver {
// JDBC Connection String
val driver = JdbcSqliteDriver("jdbc:sqlite:$dbPath")
// Initiale Schema-Erstellung prüfen
// Da wir generateAsync=true nutzen, gibt Schema.create ein Result zurück,
// auf das wir warten müssen (.await()).
// Der JDBC Treiber führt dies jedoch synchron aus.
if (!File(dbPath).exists()) {
AppDatabase.Schema.create(driver).await()
} else {
// Migrationen prüfen (vereinfacht)
val currentVersion = 1 // Logic to fetch version
if (AppDatabase.Schema.version > currentVersion) {
AppDatabase.Schema.migrate(driver, currentVersion.toLong(), AppDatabase.Schema.version).await()
}
}
return driver
}
}
```
**Das Async-Paradoxon auf der JVM:**
Das bloße Vorhandensein von `suspend` im Interface macht den JDBC-Treiber nicht nicht-blockierend. Wenn Sie eine Query in `Dispatchers.Main` aufrufen, wird die UI einfrieren, obwohl es eine `suspend fun` ist.
*Lösung:* Die Repository-Schicht (siehe Abschnitt 6) muss zwingend `withContext(Dispatchers.IO)` verwenden, um die Ausführung auf einen Hintergrund-Thread-Pool zu verlagern.
### 5.3 Web (Kotlin/JS) Implementierung: Web Worker & OPFS
Für eine echte Offline-First-Anwendung im Web reicht `localStorage` oder `IndexedDB` oft nicht aus, insbesondere bei komplexen relationalen Daten. Die modernste Lösung (Stand 2026) ist SQLite über WebAssembly (Wasm) mit dem **Origin Private File System (OPFS)** als Backend.
OPFS bietet einen performanten Dateisystemzugriff, der speziell für Datenbanken optimiert ist, erfordert aber zwingend die Ausführung in einem Web Worker, da die synchronen Zugriffsmethoden (`FileSystemSyncAccessHandle`) im Main Thread blockiert sind.
**Schritt 1: Der Worker-Code**
Wir benötigen einen Web Worker, der den SQLite Wasm Code lädt.
**`shared-kernel/src/jsMain/kotlin/WebDatabaseFactory.kt`**:
```kotlin
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import org.w3c.dom.Worker
class WebDatabaseFactory : DatabaseFactory {
override suspend fun createDriver(): SqlDriver {
// Wir instanziieren einen Worker, der auf einer dedizierten JS-Datei basiert.
// Webpack muss diese Datei korrekt verarbeiten und bereitstellen.
val worker = Worker(
js("""new URL("@cashapp/sqldelight-sqljs-worker/sqljs.worker.js", import.meta.url)""")
)
val driver = WebWorkerDriver(worker)
// Initialisierung
AppDatabase.Schema.create(driver).await()
return driver
}
}
```
*Hinweis:* Der Standard `@cashapp/sqldelight-sqljs-worker` nutzt oft noch `sql.js` (In-Memory). Für Persistenz via OPFS müssen Sie oft einen **Custom Worker** implementieren, der `sqlite-wasm` lädt und das VFS für OPFS konfiguriert.
Ein solcher Custom Worker Code (JavaScript) sähe in etwa so aus (vereinfacht):
```javascript
// custom-sqlite.worker.js
import { initBackend } from '@sqlite.org/sqlite-wasm';
addEventListener('message', async ({ data }) => {
// Initialisierung des OPFS VFS
const sqlite3 = await initBackend();
const db = new sqlite3.oo1.OpfsDb('/mydb.sqlite3');
//... Kommunikation mit SQLDelight Driver Protokoll...
});
```
**Schritt 2: Webpack Konfiguration für Worker**
Damit `import.meta.url` korrekt aufgelöst wird, muss Webpack im verbrauchenden Modul (Web-App) konfiguriert werden.
**`web-host/webpack.config.d/sql-worker.js`**:
```javascript
const CopyWebpackPlugin = require('copy-webpack-plugin');
// Kopiert die WASM Binaries an einen Ort, wo der Browser sie laden kann
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: '../../node_modules/sql.js/dist/sql-wasm.wasm',
to: 'sql-wasm.wasm'
}
]
})
);
// Fallbacks für Node-Module, die im Browser fehlen
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
crypto: false
};
```
## 6. Das Repository-Pattern: Abstraktion der Asynchronität
Das Repository ist die entscheidende Schicht, um die Unterschiede zwischen JVM (Blocking Wrapper) und JS (Native Async) zu verbergen.
### 6.1 Dispatcher Provider
Wir injizieren Dispatcher, um Testbarkeit und Plattformunterschiede zu managen.
```kotlin
// commonMain
interface DispatcherProvider {
val io: CoroutineDispatcher
val main: CoroutineDispatcher
}
// desktopMain
object DesktopDispatcherProvider : DispatcherProvider {
override val io = Dispatchers.IO
override val main = Dispatchers.Main
}
// jsMain
object JsDispatcherProvider : DispatcherProvider {
// In JS gibt es nur einen Thread. Dispatchers.Default ist hier semantisch passend,
// technisch aber auf dem gleichen Event Loop.
override val io = Dispatchers.Default
override val main = Dispatchers.Main
}
```
### 6.2 TaskRepository Implementierung
```kotlin
class TaskRepository(
private val db: AppDatabase,
private val dispatchers: DispatcherProvider
) {
private val queries = db.taskQueries
// READ: Reaktiv (Flow)
// Beobachtet die DB. Wenn sich Daten ändern, emittiert der Flow neu.
fun getTasks(): Flow<List<Task>> {
return queries.selectAll()
.asFlow()
// WICHTIG: mapToList führt die Query aus.
// Auf JVM blockiert das! Daher muss es auf Dispatchers.IO verschoben werden.
.mapToList(dispatchers.io)
.flowOn(dispatchers.io)
}
// WRITE: Suspend
suspend fun addTask(title: String) = withContext(dispatchers.io) {
val id = uuid4().toString() // Benötigt library: com.benasher44:uuid
val now = Clock.System.now().toEpochMilliseconds()
queries.transaction {
queries.insertOrReplace(
id = id,
title = title,
description = null,
is_completed = false,
created_at = now,
updated_at = now,
sync_status = "DIRTY", // Markiert für Sync
last_synced_at = null
)
}
}
// SYNC Helper
suspend fun getDirtyTasks(): List<Task> = withContext(dispatchers.io) {
queries.selectDirty().awaitAsList()
}
}
```
**Analyse der `mapToList` Problematik:**
Auf der JVM wird der Listener benachrichtigt, wenn sich die Tabelle ändert. Der Flow sammelt neu. `mapToList` iteriert über den Cursor. Da der JDBC-Treiber blockiert, geschieht dies synchron. Ohne `.flowOn(dispatchers.io)` würde dies auf dem UI-Thread passieren (wenn dort collected wird) und Ruckler verursachen. Auf JS wartet `mapToList` auf das Promise des Workers, was den Main Thread nicht blockiert, aber dennoch Rechenzeit beansprucht. Die Verwendung von `flowOn` ist also Best Practice für beide Plattformen.
## 7. Mikro-Frontend Architektur im Web
In einer Mikro-Frontend-Umgebung (z.B. basierend auf Webpack Module Federation) laden wir verschiedene Teile der Anwendung zur Laufzeit nach. Das Risiko: Jedes Mikro-Frontend könnte versuchen, seine eigene Datenbankverbindung zu öffnen. Bei OPFS/SQLite führt dies zum Absturz, da die Datei gelockt ist.
**Lösung: Shared Kernel als Singleton via Module Federation.**
### 7.1 Export des Shared Kernels
Wir müssen sicherstellen, dass Webpack das `shared-kernel` Modul nur einmal lädt und an alle Mikro-Frontends verteilt.
In `shared-kernel` (JS Target):
Wir erstellen einen expliziten Einstiegspunkt (Entry Point) für JS.
```kotlin
// shared-kernel/src/jsMain/kotlin/Entry.kt
@file:JsExport
package com.example.offlinefirst.kernel
@JsExport
class DatabaseProvider {
private var _database: AppDatabase? = null
// Initialisierungsmethode, die NUR von der Shell-App aufgerufen wird
suspend fun initialize() {
if (_database!= null) return
val factory = WebDatabaseFactory()
_database = createDatabase(factory)
}
// Zugriffsmethode für Feature-Module
fun getDatabase(): AppDatabase {
return _database?: throw Error("Database not initialized! Call initialize() in App Shell.")
}
}
// Globales Singleton
@JsExport
val dbProvider = DatabaseProvider()
```
### 7.2 Webpack Federation Konfiguration
In der `build.gradle.kts` der konsumierenden Web-Module (Shell und Features) müssen wir Webpack anweisen, das Kotlin-Laufzeitsystem und unseren Kernel zu teilen.
**`web-host/webpack.config.d/federation.js`**:
```javascript
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
config.plugins.push(
new ModuleFederationPlugin({
name: "app_shell",
remotes: {
feature_dashboard: "feature_dashboard@http://localhost:8081/remoteEntry.js",
feature_settings: "feature_settings@http://localhost:8082/remoteEntry.js"
},
shared: {
// Kotlin Standardbibliothek teilen, um `instanceof` Checks und globalen State zu erhalten
"kotlin": { singleton: true, eager: true },
"kotlinx-coroutines-core": { singleton: true, eager: true },
// Unser kompiliertes Kernel-Modul
"shared-kernel": { singleton: true, requiredVersion: "1.0.0" }
}
})
);
```
**Erklärung „Singleton & Eager“:**
* `singleton: true`: Stellt sicher, dass Webpack nur eine Version der Bibliothek lädt. Dies ist entscheidend für den Datenbank-State.
* `eager: true`: Zwingt Webpack, diese Bibliotheken sofort beim Start zu laden, statt sie asynchron nachzuladen. Dies verhindert Probleme bei der Initialisierung von Kotlin-Objekten (wie `dbProvider`), die beim App-Start verfügbar sein müssen.
## 8. Synchronisation und Offline-Logik (Store5)
Für eine robuste Synchronisation empfiehlt sich die Implementierung einer „Sync Engine“, die unabhängig vom UI läuft.
### 8.1 Die Sync-Engine
Diese Klasse überwacht die Netzwerkverbindung und synchronisiert Daten im Hintergrund.
```kotlin
class SyncEngine(
private val repository: TaskRepository,
private val api: TaskApi,
private val scope: CoroutineScope
) {
// Einfache StateFlow basierte Netzwerküberwachung (Plattform-spezifisch zu implementieren)
val isOnline = NetworkMonitor.status // Flow<Boolean>
fun start() {
scope.launch {
isOnline.collect { online ->
if (online) {
syncPush()
syncPull()
}
}
}
}
private suspend fun syncPush() {
val dirtyTasks = repository.getDirtyTasks()
dirtyTasks.forEach { task ->
try {
// Sende an API
val response = api.pushTask(task)
// Markiere lokal als Synced
repository.markAsSynced(task.id, response.syncedAt)
} catch (e: Exception) {
// Fehlerbehandlung: Exponential Backoff Logik hier einfügen
console.error("Sync failed for task ${task.id}: $e")
}
}
}
}
```
### 8.2 Hintergrund-Synchronisation
* **Android/JVM:** Hier würde man `WorkManager` nutzen, um den Sync auch dann auszuführen, wenn die App geschlossen ist.
* **Web:** Hier ist die Situation komplexer. Die Background Sync API ist in Browsern noch nicht flächendeckend stabil für alle Szenarien verfügbar. Die gängige Praxis ist, den Sync im Service Worker oder im Shared Worker auszuführen, solange mindestens ein Tab geöffnet ist.
* *Architektur-Tipp:* Nutzen Sie den gleichen Web Worker, der die Datenbank hält, auch für die Synchronisation, um den Main Thread zu entlasten.
## 9. Teststrategien
Das Testen von asynchronem Datenbankcode erfordert besondere Sorgfalt.
### 9.1 In-Memory Tests
Für Unit Tests verwenden wir In-Memory-Treiber.
* **JVM:** `JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)`
* **JS:** Hier können wir den einfachen `sql.js` Treiber ohne Worker nutzen, da Performance im Unit Test sekundär ist und wir keine Persistenz brauchen.
```kotlin
// shared-kernel/src/commonTest/kotlin/TaskRepositoryTest.kt
class TaskRepositoryTest {
@Test
fun testInsertAndRead() = runTest {
// Setup Driver (Platform specific helper)
val driver = createTestDriver()
val db = AppDatabase(driver)
AppDatabase.Schema.create(driver).await() // Auch im Test await()!
val repo = TaskRepository(db, TestDispatcherProvider)
repo.addTask("Test Task")
val tasks = repo.getTasks().first()
assertEquals(1, tasks.size)
assertEquals("Test Task", tasks.title)
assertEquals("DIRTY", tasks.sync_status)
}
}
```
*Hinweis:* `runTest` aus `kotlinx-coroutines-test` ist essenziell, um Coroutinen in Tests deterministisch auszuführen.
## 10. Fazit und Ausblick
Die Implementierung einer Offline-First-Architektur mit Kotlin Multiplatform, SQLDelight und Mikro-Frontends ist im Jahr 2026 eine leistungsfähige, wenn auch komplexe Lösung.
**Schlüsselerkenntnisse:**
1. **Konfiguration ist der Schlüssel:** Die korrekte Einstellung von `generateAsync = true` in SQLDelight und die Webpack Federation Config (`singleton: true`) sind die häufigsten Fehlerquellen.
2. **Threading-Modelle verstehen:** Der Entwickler muss sich stets bewusst sein, ob Code auf der blockierenden JVM oder im asynchronen JS-Event-Loop läuft. Das `DispatcherProvider`-Muster ist hierfür unerlässlich.
3. **Persistenz im Web:** OPFS ist der Game-Changer, der echte SQLite-Performance in den Browser bringt und `IndexedDB` als primären Speicher für komplexe Daten ablöst.
Durch die Einhaltung der in diesem Bericht dargelegten Muster lässt sich eine Anwendung erstellen, die sich für den Nutzer wie eine native App anfühlt reaktionsschnell, robust gegen Netzwerkfehler und nahtlos über Plattformen hinweg synchronisiert.
---
**Referenzen im Kontext:**
- Kotlin Versionierung und Release-Zyklen.
- Asynchrones Treiber-Verhalten und Konfiguration.
- Web Worker Setup und NPM Abhängigkeiten.
- Web Persistenztechnologien (OPFS).
- Webpack Module Federation Strategien.
- Hintergrund-Synchronisation.
```
```
+1 -1
View File
@@ -3,4 +3,4 @@
Dieses Modul enthält den gesamten Code für das Kotlin Multiplatform (KMP) Frontend "Meldestelle Portal".
**Die vollständige Dokumentation befindet sich hier:**
[**-> docs/06_Frontend/README.md**](../docs/06_Frontend/README.md)
[** docs/06_Frontend/README.md**](../docs/06_Frontend/README.md)
@@ -22,23 +22,23 @@ import at.mocode.frontend.core.designsystem.theme.Dimens
*/
@Composable
fun DenseButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primary
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primary
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(32.dp), // Fixe, kompakte Höhe
shape = MaterialTheme.shapes.small, // Nutzt unsere 4dp Rundung
colors = ButtonDefaults.buttonColors(containerColor = containerColor),
contentPadding = PaddingValues(horizontal = Dimens.SpacingM, vertical = 0.dp) // Wenig Padding
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium // Kleinere Schrift
)
}
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(32.dp), // Fixe, kompakte Höhe
shape = MaterialTheme.shapes.small, // Nutzt unsere 4dp Rundung
colors = ButtonDefaults.buttonColors(containerColor = containerColor),
contentPadding = PaddingValues(horizontal = Dimens.SpacingM, vertical = 0.dp) // Wenig Padding
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium // Kleinere Schrift
)
}
}
@@ -21,22 +21,22 @@ import at.mocode.frontend.core.designsystem.theme.Dimens
*/
@Composable
fun DashboardCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), // Dünner Rahmen
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // Kein Schatten
Card(
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), // Dünner Rahmen
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // Kein Schatten
) {
Column(
modifier = Modifier.padding(Dimens.SpacingS) // Kompaktes Padding innen
) {
Column(
modifier = Modifier.padding(Dimens.SpacingS) // Kompaktes Padding innen
) {
content()
}
content()
}
}
}
@@ -18,89 +18,89 @@ import androidx.compose.ui.unit.sp
// Blau steht für Aktion/Information, Grau für Struktur.
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF0052CC), // Enterprise Blue (stark)
onPrimary = Color.White,
primaryContainer = Color(0xFFDEEBFF),
onPrimaryContainer = Color(0xFF0052CC),
primary = Color(0xFF0052CC), // Enterprise Blue (stark)
onPrimary = Color.White,
primaryContainer = Color(0xFFDEEBFF),
onPrimaryContainer = Color(0xFF0052CC),
secondary = Color(0xFF2684FF), // Helleres Blau für Akzente
onSecondary = Color.White,
secondary = Color(0xFF2684FF), // Helleres Blau für Akzente
onSecondary = Color.White,
background = Color(0xFFF4F5F7), // Helles Grau (nicht hartes Weiß)
surface = Color.White,
onBackground = Color(0xFF172B4D), // Fast Schwarz (besser lesbar als #000)
onSurface = Color(0xFF172B4D),
background = Color(0xFFF4F5F7), // Helles Grau (nicht hartes Weiß)
surface = Color.White,
onBackground = Color(0xFF172B4D), // Fast Schwarz (besser lesbar als #000)
onSurface = Color(0xFF172B4D),
error = Color(0xFFDE350B),
onError = Color.White
error = Color(0xFFDE350B),
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF4C9AFF), // Helleres Blau auf Dunkel
onPrimary = Color(0xFF091E42),
primaryContainer = Color(0xFF0052CC),
onPrimaryContainer = Color.White,
primary = Color(0xFF4C9AFF), // Helleres Blau auf Dunkel
onPrimary = Color(0xFF091E42),
primaryContainer = Color(0xFF0052CC),
onPrimaryContainer = Color.White,
secondary = Color(0xFF2684FF),
onSecondary = Color.White,
secondary = Color(0xFF2684FF),
onSecondary = Color.White,
background = Color(0xFF1E1E1E), // Dunkles Grau (angenehmer als #000)
surface = Color(0xFF2C2C2C), // Panels heben sich leicht ab
onBackground = Color(0xFFEBECF0),
onSurface = Color(0xFFEBECF0),
background = Color(0xFF1E1E1E), // Dunkles Grau (angenehmer als #000)
surface = Color(0xFF2C2C2C), // Panels heben sich leicht ab
onBackground = Color(0xFFEBECF0),
onSurface = Color(0xFFEBECF0),
error = Color(0xFFFF5630),
onError = Color.Black
error = Color(0xFFFF5630),
onError = Color.Black
)
// --- 2. Formen (Shapes) ---
// Enterprise Apps nutzen oft weniger Rundung als Consumer Apps (seriöser).
private val AppShapes = Shapes(
small = RoundedCornerShape(Dimens.CornerRadiusS), // Buttons, Inputs
medium = RoundedCornerShape(Dimens.CornerRadiusM), // Cards, Dialogs
large = RoundedCornerShape(Dimens.CornerRadiusM)
small = RoundedCornerShape(Dimens.CornerRadiusS), // Buttons, Inputs
medium = RoundedCornerShape(Dimens.CornerRadiusM), // Cards, Dialogs
large = RoundedCornerShape(Dimens.CornerRadiusM)
)
// --- 3. Typografie ---
// wir setzen auf klare Hierarchie.
private val AppTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle( // Standard Text
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle( // Für dichte Tabellen/Labels
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle( // Standard Text
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle( // Für dichte Tabellen/Labels
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
)
@Composable
fun AppTheme(
darkTheme: Boolean = false, // Kann später via Settings gesteuert werden
content: @Composable () -> Unit
darkTheme: Boolean = false, // Kann später via Settings gesteuert werden
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
shapes = AppShapes,
typography = AppTypography,
content = content
)
MaterialTheme(
colorScheme = colorScheme,
shapes = AppShapes,
typography = AppTypography,
content = content
)
}
@@ -8,20 +8,20 @@ import androidx.compose.ui.unit.dp
* Wenn wir den Abstand global ändern wollen, machen wir das nur hier.
*/
object Dimens {
// Spacing (Abstände)
val SpacingXS = 4.dp // Sehr eng (für Tabellen, dichte Listen)
val SpacingS = 8.dp // Standard Abstand zwischen Elementen
val SpacingM = 16.dp // Abstand für Sektionen
val SpacingL = 24.dp // Außenabstand für Screens
// Spacing (Abstände)
val SpacingXS = 4.dp // Sehr eng (für Tabellen, dichte Listen)
val SpacingS = 8.dp // Standard Abstand zwischen Elementen
val SpacingM = 16.dp // Abstand für Sektionen
val SpacingL = 24.dp // Außenabstand für Screens
// Sizes (Größen)
val IconSizeS = 16.dp
val IconSizeM = 24.dp
// Sizes (Größen)
val IconSizeS = 16.dp
val IconSizeM = 24.dp
// Borders
val BorderThin = 1.dp
// Borders
val BorderThin = 1.dp
// Corner Radius (Ecken)
val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look)
val CornerRadiusM = 8.dp
// Corner Radius (Ecken)
val CornerRadiusS = 4.dp // Leicht abgerundet (Enterprise Look)
val CornerRadiusM = 8.dp
}
@@ -4,5 +4,5 @@ import app.cash.sqldelight.db.SqlDriver
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class DatabaseDriverFactory() {
suspend fun createDriver(): SqlDriver
suspend fun createDriver(): SqlDriver
}
@@ -6,23 +6,25 @@ import org.w3c.dom.Worker
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class DatabaseDriverFactory {
actual suspend fun createDriver(): SqlDriver {
// Wir nutzen eine Helper-Funktion, um den Worker zu erstellen.
// Dies ermöglicht uns, 'new URL(..., import.meta.url)' in JS zu verwenden,
// was Webpack dazu bringt, den Pfad korrekt aufzulösen.
val worker = createWorker()
val driver = WebWorkerDriver(worker)
actual suspend fun createDriver(): SqlDriver {
// Wir nutzen eine Helper-Funktion, um den Worker zu erstellen.
// Dies ermöglicht uns, 'new URL(..., import.meta.url)' in JS zu verwenden,
// was Webpack dazu bringt, den Pfad korrekt aufzulösen.
val worker = createWorker()
val driver = WebWorkerDriver(worker)
// Initialize schema asynchronously
AppDatabase.Schema.create(driver).await()
// Initialize schema asynchronously
AppDatabase.Schema.create(driver).await()
return driver
}
return driver
}
}
// Helper function to create the worker using proper URL resolution
private fun createWorker(): Worker {
return js("""
return js(
"""
new Worker(new URL('sqlite.worker.js', import.meta.url), { type: 'module' })
""")
"""
)
}
@@ -1,103 +1,109 @@
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
// We do NOT import from node_modules anymore to avoid Webpack bundling issues.
// import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
console.log("Worker: sqlite.worker.js loaded. Starting initialization...");
// Minimal worker protocol compatible with SQLDelight's `web-worker-driver`.
// Mirrors the message format used by SQLDelight's `sqljs.worker.js` implementation.
function runWorker({ driver }) {
console.log("Worker: runWorker called");
let db = null;
const open = (name) => {
console.log("Worker: Opening database", name);
db = driver.open(name);
};
function runWorker({driver}) {
console.log("Worker: runWorker called");
let db = null;
const open = (name) => {
console.log("Worker: Opening database", name);
db = driver.open(name);
};
// Open once with the default database name expected by SQLDelight.
open('app.db');
// Open once with the default database name expected by SQLDelight.
open('app.db');
self.onmessage = (event) => {
const data = event.data;
try {
switch (data && data.action) {
case 'exec': {
if (!data.sql) throw new Error('exec: Missing query string');
// sqlite-wasm oo1 DB supports `.exec(...)`.
// We intentionally return only `values` which is sufficient for SQLDelight.
const rows = [];
db.exec({
sql: data.sql,
bind: data.params ?? [],
rowMode: 'array',
callback: (row) => rows.push(row)
});
return postMessage({ id: data.id, results: { values: rows } });
}
case 'begin_transaction':
db.exec('BEGIN TRANSACTION;');
return postMessage({ id: data.id, results: [] });
case 'end_transaction':
db.exec('END TRANSACTION;');
return postMessage({ id: data.id, results: [] });
case 'rollback_transaction':
db.exec('ROLLBACK TRANSACTION;');
return postMessage({ id: data.id, results: [] });
default:
throw new Error(`Unsupported action: ${data && data.action}`);
}
} catch (err) {
console.error("Worker: Error processing message", err);
return postMessage({ id: data && data.id, error: err?.message ?? String(err) });
self.onmessage = (event) => {
const data = event.data;
try {
switch (data && data.action) {
case 'exec': {
if (!data.sql) throw new Error('exec: Missing query string');
const rows = [];
db.exec({
sql: data.sql,
bind: data.params ?? [],
rowMode: 'array',
callback: (row) => rows.push(row)
});
return postMessage({id: data.id, results: {values: rows}});
}
};
case 'begin_transaction':
db.exec('BEGIN TRANSACTION;');
return postMessage({id: data.id, results: []});
case 'end_transaction':
db.exec('END TRANSACTION;');
return postMessage({id: data.id, results: []});
case 'rollback_transaction':
db.exec('ROLLBACK TRANSACTION;');
return postMessage({id: data.id, results: []});
default:
throw new Error(`Unsupported action: ${data && data.action}`);
}
} catch (err) {
console.error("Worker: Error processing message", err);
return postMessage({id: data && data.id, error: err?.message ?? String(err)});
}
};
}
// Error handling wrapper
self.onerror = function(event) {
console.error("Error in Web Worker (onerror):", event.message, event.filename, event.lineno);
// Optionally, send the error back to the main thread
self.postMessage({ type: 'error', message: event.message, filename: event.filename, lineno: event.lineno });
self.onerror = function (event) {
console.error("Error in Web Worker (onerror):", event.message, event.filename, event.lineno);
self.postMessage({type: 'error', message: event.message, filename: event.filename, lineno: event.lineno});
};
// Manually fetch the WASM file to bypass Webpack/sqlite-wasm loading issues
async function init() {
try {
console.log("Worker: Fetching sqlite3.wasm manually...");
const response = await fetch('/sqlite3.wasm');
if (!response.ok) {
throw new Error(`Failed to fetch sqlite3.wasm: ${response.status} ${response.statusText}`);
}
const wasmBinary = await response.arrayBuffer();
console.log("Worker: sqlite3.wasm fetched successfully, size:", wasmBinary.byteLength);
try {
// 1. Load the sqlite3.js library manually via importScripts.
// This file is copied to the root by Webpack (CopyWebpackPlugin).
// This bypasses Webpack's module resolution for the library itself.
console.log("Worker: Loading sqlite3.js via importScripts...");
importScripts('sqlite3.js');
console.log("Worker: Calling sqlite3InitModule with wasmBinary...");
const sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
wasmBinary: wasmBinary // Provide the binary directly!
});
console.log("Worker: sqlite3InitModule resolved successfully");
const opfsAvailable = 'opfs' in sqlite3;
console.log("Worker: OPFS available:", opfsAvailable);
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
});
} catch (e) {
console.error("Database initialization error in worker:", e);
self.postMessage({ type: 'error', message: 'Database initialization failed: ' + e.message });
// After importScripts, `sqlite3InitModule` should be available globally.
if (typeof self.sqlite3InitModule !== 'function') {
throw new Error("sqlite3InitModule is not defined after importScripts. Check if sqlite3.js was loaded correctly.");
}
console.log("Worker: Fetching sqlite3.wasm manually...");
const response = await fetch('sqlite3.wasm');
if (!response.ok) {
throw new Error(`Failed to fetch sqlite3.wasm: ${response.status} ${response.statusText}`);
}
const wasmBinary = await response.arrayBuffer();
console.log("Worker: sqlite3.wasm fetched successfully, size:", wasmBinary.byteLength);
console.log("Worker: Calling sqlite3InitModule with wasmBinary...");
const sqlite3 = await self.sqlite3InitModule({
print: console.log,
printErr: console.error,
wasmBinary: wasmBinary
});
console.log("Worker: sqlite3InitModule resolved successfully");
const opfsAvailable = 'opfs' in sqlite3;
console.log("Worker: OPFS available:", opfsAvailable);
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
});
} catch (e) {
console.error("Database initialization error in worker:", e);
self.postMessage({type: 'error', message: 'Database initialization failed: ' + e.message});
}
}
init();
@@ -4,31 +4,32 @@ import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import java.io.File
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class DatabaseDriverFactory {
actual suspend fun createDriver(): SqlDriver {
// For desktop, we use a persistent file database
// In dev mode, we might want to use a temporary file or user home
val dbFile = File(System.getProperty("user.home"), ".meldestelle/app_database.db")
dbFile.parentFile.mkdirs()
actual suspend fun createDriver(): SqlDriver {
// For desktop, we use a persistent file database
// In dev mode, we might want to use a temporary file or user home
val dbFile = File(System.getProperty("user.home"), ".meldestelle/app_database.db")
dbFile.parentFile.mkdirs()
val driver = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}")
val driver = JdbcSqliteDriver("jdbc:sqlite:${dbFile.absolutePath}")
// Schema creation/migration needs to be handled carefully.
// For now, we just create it if it doesn't exist.
// In a real app, we'd check version and migrate.
// Since generateAsync=true, the Schema.create signature might be suspend or return AsyncResult.
// However, JdbcSqliteDriver is synchronous. We might need to wrap or await.
// But wait! Schema.create(driver) returns void or Unit usually.
// Let's check the generated code later. For now, we assume standard behavior.
// Schema creation/migration needs to be handled carefully.
// For now, we just create it if it doesn't exist.
// In a real app, we'd check version and migrate.
// Since generateAsync=true, the Schema.create signature might be suspend or return AsyncResult.
// However, JdbcSqliteDriver is synchronous. We might need to wrap or await.
// But wait! Schema.create(driver) returns void or Unit usually.
// Let's check the generated code later. For now, we assume standard behavior.
try {
AppDatabase.Schema.create(driver).await()
} catch (e: Exception) {
// Schema might already exist.
// SQLDelight doesn't have "createIfNotExists" built-in easily without version check.
// We'll leave this simple for now and refine with proper migration logic later.
}
return driver
try {
AppDatabase.Schema.create(driver).await()
} catch (e: Exception) {
// Schema might already exist.
// SQLDelight doesn't have "createIfNotExists" built-in easily without version check.
// We'll leave this simple for now and refine with proper migration logic later.
}
return driver
}
}
@@ -2,82 +2,82 @@ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
// Minimal worker protocol compatible with SQLDelight's `web-worker-driver`.
// Mirrors the message format used by SQLDelight's `sqljs.worker.js` implementation.
function runWorker({ driver }) {
let db = null;
const open = (name) => {
db = driver.open(name);
};
function runWorker({driver}) {
let db = null;
const open = (name) => {
db = driver.open(name);
};
// Open once with the default database name expected by SQLDelight.
open('app.db');
// Open once with the default database name expected by SQLDelight.
open('app.db');
self.onmessage = (event) => {
const data = event.data;
try {
switch (data && data.action) {
case 'exec': {
if (!data.sql) throw new Error('exec: Missing query string');
const rows = [];
db.exec({
sql: data.sql,
bind: data.params ?? [],
rowMode: 'array',
callback: (row) => rows.push(row)
});
return postMessage({ id: data.id, results: { values: rows } });
}
case 'begin_transaction':
db.exec('BEGIN TRANSACTION;');
return postMessage({ id: data.id, results: [] });
case 'end_transaction':
db.exec('END TRANSACTION;');
return postMessage({ id: data.id, results: [] });
case 'rollback_transaction':
db.exec('ROLLBACK TRANSACTION;');
return postMessage({ id: data.id, results: [] });
default:
throw new Error(`Unsupported action: ${data && data.action}`);
}
} catch (err) {
return postMessage({ id: data && data.id, error: err?.message ?? String(err) });
self.onmessage = (event) => {
const data = event.data;
try {
switch (data && data.action) {
case 'exec': {
if (!data.sql) throw new Error('exec: Missing query string');
const rows = [];
db.exec({
sql: data.sql,
bind: data.params ?? [],
rowMode: 'array',
callback: (row) => rows.push(row)
});
return postMessage({id: data.id, results: {values: rows}});
}
};
case 'begin_transaction':
db.exec('BEGIN TRANSACTION;');
return postMessage({id: data.id, results: []});
case 'end_transaction':
db.exec('END TRANSACTION;');
return postMessage({id: data.id, results: []});
case 'rollback_transaction':
db.exec('ROLLBACK TRANSACTION;');
return postMessage({id: data.id, results: []});
default:
throw new Error(`Unsupported action: ${data && data.action}`);
}
} catch (err) {
return postMessage({id: data && data.id, error: err?.message ?? String(err)});
}
};
}
// Error handling wrapper
self.onerror = function(event) {
console.error("Error in Web Worker:", event.message, event.filename, event.lineno);
// Optionally, send the error back to the main thread
self.postMessage({ type: 'error', message: event.message, filename: event.filename, lineno: event.lineno });
self.onerror = function (event) {
console.error("Error in Web Worker:", event.message, event.filename, event.lineno);
// Optionally, send the error back to the main thread
self.postMessage({type: 'error', message: event.message, filename: event.filename, lineno: event.lineno});
};
try {
sqlite3InitModule({
print: console.log,
printErr: console.error,
}).then((sqlite3) => {
try {
const opfsAvailable = 'opfs' in sqlite3;
sqlite3InitModule({
print: console.log,
printErr: console.error,
}).then((sqlite3) => {
try {
const opfsAvailable = 'opfs' in sqlite3;
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
});
} catch (e) {
console.error("Database initialization error in worker (inner):", e);
self.postMessage({ type: 'error', message: 'Database initialization failed (inner): ' + e.message });
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
});
});
} catch (e) {
console.error("Database initialization error in worker (inner):", e);
self.postMessage({type: 'error', message: 'Database initialization failed (inner): ' + e.message});
}
});
} catch (e) {
console.error("Database initialization error in worker (outer):", e);
self.postMessage({ type: 'error', message: 'Database initialization failed (outer): ' + e.message });
console.error("Database initialization error in worker (outer):", e);
self.postMessage({type: 'error', message: 'Database initialization failed (outer): ' + e.message});
}
@@ -8,7 +8,9 @@ import kotlin.test.assertTrue
private class FakeNav : NavigationPort {
var last: String? = null
override fun navigateTo(route: String) { last = route }
override fun navigateTo(route: String) {
last = route
}
}
private class FakeUserProvider(private val user: User?) : CurrentUserProvider {
@@ -11,7 +11,7 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
/**
* Simple token provider interface so core network module does not depend on auth-feature.
* Simple token provider interface so the core network module does not depend on auth-feature.
*/
interface TokenProvider {
fun getAccessToken(): String?
@@ -41,7 +41,11 @@ val networkModule = module {
// 2. API Client (Configured for Gateway & Auth Header)
single(named("apiClient")) {
val tokenProvider: TokenProvider? = try { get<TokenProvider>() } catch (_: Throwable) { null }
val tokenProvider: TokenProvider? = try {
get<TokenProvider>()
} catch (_: Throwable) {
null
}
HttpClient {
// JSON (kotlinx) configuration
install(ContentNegotiation) {
@@ -79,7 +83,7 @@ val networkModule = module {
// Inject Authorization header if token is present
val token = tokenProvider?.getAccessToken()
if (token != null) {
header("Authorization", "Bearer $token")
header("Authorization", "Bearer $token")
}
}
@@ -13,9 +13,9 @@ actual object PlatformConfig {
// In Wasm, we can access a window directly if we are in the browser main thread.
// However, we need to be careful about exceptions.
val origin = try {
window.location.origin
window.location.origin
} catch (e: Throwable) {
null
null
}
if (!origin.isNullOrBlank()) return origin.removeSuffix("/")
@@ -28,9 +28,11 @@ actual object PlatformConfig {
// Helper function for JS interop in Wasm
// Kotlin/Wasm does not support 'dynamic' type or complex js() blocks inside functions.
// We must use top-level external functions or simple js() expressions.
private fun getGlobalApiBaseUrl(): String = js("""
private fun getGlobalApiBaseUrl(): String = js(
"""
(function() {
var global = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : {}));
return (global.API_BASE_URL && typeof global.API_BASE_URL === 'string') ? global.API_BASE_URL : "";
})()
""")
"""
)
@@ -23,274 +23,274 @@ import at.mocode.frontend.core.designsystem.theme.Dimens
@Composable
fun PingScreen(
viewModel: PingViewModel,
onBack: () -> Unit = {}
viewModel: PingViewModel,
onBack: () -> Unit = {}
) {
val uiState = viewModel.uiState
val uiState = viewModel.uiState
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingS) // Globales Spacing
) {
// 1. Header
PingHeader(
onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
Spacer(Modifier.height(Dimens.SpacingS))
// 2. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingS) // Globales Spacing
) {
// 1. Header
PingHeader(
onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
.weight(0.6f)
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
ActionToolbar(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
// 2. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
ActionToolbar(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
// Right Panel: Terminal Log (40%)
// Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
DashboardCard(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
) {
LogHeader(onClear = { viewModel.clearLogs() })
LogConsole(uiState.logs)
}
}
Spacer(Modifier.height(Dimens.SpacingXS))
// 3. Footer
PingStatusBar(uiState.lastSyncResult)
// Right Panel: Terminal Log (40%)
// Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
DashboardCard(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
) {
LogHeader(onClear = { viewModel.clearLogs() })
LogConsole(uiState.logs)
}
}
Spacer(Modifier.height(Dimens.SpacingXS))
// 3. Footer
PingStatusBar(uiState.lastSyncResult)
}
}
@Composable
private fun PingHeader(
onBack: () -> Unit,
isSyncing: Boolean,
isLoading: Boolean
onBack: () -> Unit,
isSyncing: Boolean,
isLoading: Boolean
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(40.dp)
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Text(
"PING SERVICE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
)
if (isLoading) {
StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Spacer(Modifier.width(Dimens.SpacingS))
}
if (isSyncing) {
StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Dimens.SpacingS))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
StatusBadge("IDLE", Color(0xFF388E3C)) // Green
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(40.dp)
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Text(
"PING SERVICE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
)
if (isLoading) {
StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Spacer(Modifier.width(Dimens.SpacingS))
}
if (isSyncing) {
StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Dimens.SpacingS))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
StatusBadge("IDLE", Color(0xFF388E3C)) // Green
}
}
}
@Composable
private fun StatusBadge(text: String, color: Color) {
Surface(
color = color.copy(alpha = 0.1f),
contentColor = color,
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
Surface(
color = color.copy(alpha = 0.1f),
contentColor = color,
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
@Composable
private fun ActionToolbar(viewModel: PingViewModel) {
// Wrap buttons to avoid overflow on small screens
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() })
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })
DenseButton(text = "Secure", onClick = { viewModel.performSecurePing() })
DenseButton(text = "Health", onClick = { viewModel.performHealthCheck() })
DenseButton(
text = "Sync",
onClick = { viewModel.triggerSync() },
containerColor = MaterialTheme.colorScheme.secondary
)
}
// Wrap buttons to avoid overflow on small screens
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
) {
DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() })
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })
DenseButton(text = "Secure", onClick = { viewModel.performSecurePing() })
DenseButton(text = "Health", onClick = { viewModel.performHealthCheck() })
DenseButton(
text = "Sync",
onClick = { viewModel.triggerSync() },
containerColor = MaterialTheme.colorScheme.secondary
)
}
}
@Composable
private fun StatusGrid(uiState: PingUiState) {
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
// Row 1
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("SIMPLE / SECURE PING")
if (uiState.simplePingResponse != null) {
KeyValueRow("Status", uiState.simplePingResponse.status)
KeyValueRow("Service", uiState.simplePingResponse.service)
KeyValueRow("Time", uiState.simplePingResponse.timestamp)
} else {
EmptyStateText()
}
}
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("HEALTH CHECK")
if (uiState.healthResponse != null) {
KeyValueRow("Status", uiState.healthResponse.status)
KeyValueRow("Healthy", uiState.healthResponse.healthy.toString())
KeyValueRow("Service", uiState.healthResponse.service)
} else {
EmptyStateText()
}
}
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
// Row 1
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("SIMPLE / SECURE PING")
if (uiState.simplePingResponse != null) {
KeyValueRow("Status", uiState.simplePingResponse.status)
KeyValueRow("Service", uiState.simplePingResponse.service)
KeyValueRow("Time", uiState.simplePingResponse.timestamp)
} else {
EmptyStateText()
}
}
// Row 2
DashboardCard(modifier = Modifier.fillMaxWidth()) {
StatusHeader("ENHANCED PING (RESILIENCE)")
if (uiState.enhancedPingResponse != null) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Status", uiState.enhancedPingResponse.status)
KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp)
}
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState)
KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms")
}
}
} else {
EmptyStateText()
}
DashboardCard(modifier = Modifier.weight(1f)) {
StatusHeader("HEALTH CHECK")
if (uiState.healthResponse != null) {
KeyValueRow("Status", uiState.healthResponse.status)
KeyValueRow("Healthy", uiState.healthResponse.healthy.toString())
KeyValueRow("Service", uiState.healthResponse.service)
} else {
EmptyStateText()
}
}
}
// Row 2
DashboardCard(modifier = Modifier.fillMaxWidth()) {
StatusHeader("ENHANCED PING (RESILIENCE)")
if (uiState.enhancedPingResponse != null) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Status", uiState.enhancedPingResponse.status)
KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp)
}
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState)
KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms")
}
}
} else {
EmptyStateText()
}
}
}
}
@Composable
private fun StatusHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Spacer(Modifier.height(Dimens.SpacingXS))
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Spacer(Modifier.height(Dimens.SpacingXS))
}
@Composable
private fun EmptyStateText() {
Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun KeyValueRow(key: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = "$key:",
modifier = Modifier.width(100.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = "$key:",
modifier = Modifier.width(100.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// --- Log Components (Terminal Style - intentionally distinct) ---
@Composable
private fun LogHeader(onClear: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
TextButton(
onClick = onClear,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp)
) {
Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
TextButton(
onClick = onClear,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp)
) {
Text("CLEAR", style = MaterialTheme.typography.labelSmall)
}
Text("CLEAR", style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun LogConsole(logs: List<LogEntry>) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Always dark for terminal
.padding(Dimens.SpacingXS),
reverseLayout = false
) {
items(logs) { log ->
val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55)
Text(
text = "[${log.timestamp}] [${log.source}] ${log.message}",
color = color,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 14.sp
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Always dark for terminal
.padding(Dimens.SpacingXS),
reverseLayout = false
) {
items(logs) { log ->
val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55)
Text(
text = "[${log.timestamp}] [${log.source}] ${log.message}",
color = color,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 14.sp
)
}
}
}
@Composable
private fun PingStatusBar(lastSync: String?) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lastSync ?: "Ready",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp)
)
}
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lastSync ?: "Ready",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp)
)
}
}
@@ -16,10 +16,10 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
data class LogEntry(
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
)
data class PingUiState(
@@ -43,7 +43,9 @@ class PingViewModel(
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${now.second.toString().padStart(2, '0')}"
val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${
now.second.toString().padStart(2, '0')
}"
val entry = LogEntry(timeString, source, message, isError)
uiState = uiState.copy(logs = listOf(entry) + uiState.logs) // Prepend for newest first
}
+98 -82
View File
@@ -1,93 +1,109 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
jvm("desktop")
jvm("desktop")
js(IR) {
browser()
binaries.executable()
js(IR) {
// WICHTIG: Als Library kompilieren für Webpack Federation
binaries.library()
generateTypeScriptDefinitions()
browser {
commonWebpackConfig {
cssSupport {
enabled.set(true)
}
}
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
*/
sourceSets {
commonMain {
dependencies {
implementation(projects.frontend.core.domain)
// implementation(projects.frontend.core.designSystem) // REMOVED: Circular dependency
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.localDb)
// Features - REMOVED: Circular dependency. Shared should NOT depend on features.
// implementation(projects.frontend.features.authFeature)
// implementation(projects.frontend.features.pingFeature)
// KMP Bundles
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
// Ktor (used directly in shared/di and shared/network)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.serialization.kotlinx.json)
// Serialization
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// implementation(libs.sqldelight.coroutines) // Wird transitiv über core:localDb geladen
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
// Koin
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
// implementation(libs.sqldelight.driver.sqlite) // Wird transitiv über core:localDb geladen
}
}
val jsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
// implementation(libs.sqldelight.driver.web) // Wird transitiv über core:localDb geladen
// Webpack Plugin für Federation Support (falls benötigt)
implementation(devNpm("copy-webpack-plugin", "12.0.0"))
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
val wasmJsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
*/
sourceSets {
commonMain {
dependencies {
implementation(projects.frontend.core.domain)
// implementation(projects.frontend.core.designSystem) // REMOVED: Circular dependency
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.localDb)
// Features - REMOVED: Circular dependency. Shared should NOT depend on features.
// implementation(projects.frontend.features.authFeature)
// implementation(projects.frontend.features.pingFeature)
// KMP Bundles
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
// Ktor (used directly in shared/di and shared/network)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.serialization.kotlinx.json)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
// Koin
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
}
}
val jsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
/*
val wasmJsMain by getting {
dependencies {
implementation(libs.ktor.client.js)
}
}
*/
}
}
}
@@ -1,6 +1,5 @@
package at.mocode.shared.network
import at.mocode.shared.domain.model.ApiError
import kotlinx.coroutines.delay
@@ -51,8 +51,6 @@ kotlin {
}
// Browser-Tests komplett deaktivieren (Configuration Cache kompatibel)
testTask {
// enabled = false
useKarma {
useChromeHeadless()
environment("CHROME_BIN", "/usr/bin/google-chrome-stable")
@@ -62,15 +60,6 @@ kotlin {
binaries.executable()
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
*/
sourceSets {
commonMain.dependencies {
// Shared modules
@@ -114,18 +103,6 @@ kotlin {
implementation(devNpm("copy-webpack-plugin", "11.0.0"))
}
/*
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
*/
commonTest.dependencies {
implementation(libs.kotlin.test)
}
@@ -136,104 +113,28 @@ kotlin {
// SQLDelight WebWorker (OPFS) resource
// ---------------------------------------------------------------------------
// `:frontend:core:local-db` ships `sqlite.worker.js` as a JS resource.
// When bundling the final JS app, webpack resolves `new URL("sqlite.worker.js", import.meta.url)`
// relative to the Kotlin JS package folder (root build dir). We therefore copy the worker into
// that folder before webpack runs.
// We need to ensure this worker file is available in the output directory so the browser can load it.
// The WASM file itself is handled by Webpack (via CopyWebpackPlugin in webpack.config.d/sqlite-config.js).
// HACK: Overwrite sqlite3.wasm in node_modules with a dummy JS file to fool Webpack
val patchSqliteWasmInNodeModules by tasks.registering(Copy::class) {
dependsOn(rootProject.tasks.named("kotlinNpmInstall"))
val copySqliteWorkerToWebpackSource by tasks.registering(Copy::class) {
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"))
// We take our dummy.js
from(localDb.layout.buildDirectory.file("processedResources/js/main/dummy.js")) {
rename { "sqlite3.wasm" } // Rename it to sqlite3.wasm
}
// And copy it OVER the original wasm file in node_modules
into(rootProject.layout.buildDirectory.dir("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm"))
// Force overwrite
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
val copySqliteAssetsToWebpackSource by tasks.registering(Copy::class) {
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"), rootProject.tasks.named("kotlinNpmInstall"))
// Explicit dependency on the patch task to ensure we copy the REAL wasm file before it gets patched?
// NO! We want to copy the REAL wasm file to the output, but PATCH the one in node_modules.
// So we must copy the real one BEFORE patching.
// But wait, copySqliteAssetsToWebpackSource copies FROM node_modules.
// If we patch node_modules first, we copy the dummy file!
// So: copySqliteAssetsToWebpackSource must run BEFORE patchSqliteWasmInNodeModules?
// Or we copy from a different source (e.g. the original npm package cache? No access).
// Better: We copy the real wasm file from node_modules to a temporary location FIRST,
// then patch node_modules, then copy from temp to output.
// Actually, we can just copy from node_modules BEFORE the patch task runs.
// But Gradle task ordering is tricky.
// Let's change the source of the copy. We can't easily access the original npm package.
// But we know that `kotlinNpmInstall` restores the original files.
// So the order must be:
// 1. kotlinNpmInstall (restores original sqlite3.wasm)
// 2. copySqliteAssetsToWebpackSource (copies original sqlite3.wasm to output)
// 3. patchSqliteWasmInNodeModules (overwrites sqlite3.wasm with dummy.js)
// 4. webpack (uses dummy.js)
mustRunAfter(rootProject.tasks.named("kotlinNpmInstall"))
from(localDb.layout.buildDirectory.file("processedResources/js/main/sqlite.worker.js"))
from(rootProject.layout.buildDirectory.file("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm"))
// Root build directory where Kotlin JS packages are assembled.
// This is one of the directories served by webpack-dev-server for static content.
into(rootProject.layout.buildDirectory.dir("js/packages/${rootProject.name}-frontend-shells-meldestelle-portal/kotlin"))
}
// Additional task to copy the worker and its wasm dependency to the distribution folder (for production build)
val copySqliteAssetsToDist by tasks.registering(Copy::class) {
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"), rootProject.tasks.named("kotlinNpmInstall"))
// Same logic here: copy before patch
mustRunAfter(rootProject.tasks.named("kotlinNpmInstall"))
from(localDb.layout.buildDirectory.file("processedResources/js/main/sqlite.worker.js"))
from(rootProject.layout.buildDirectory.file("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm"))
// Copy to the distribution directory where index.html resides
into(layout.buildDirectory.dir("dist/js/productionExecutable"))
}
// Ensure the assets are present for the production bundle.
tasks.named("jsBrowserProductionWebpack") {
dependsOn(copySqliteAssetsToWebpackSource)
dependsOn(patchSqliteWasmInNodeModules)
// Enforce order: Copy real assets first, then patch node_modules
// patchSqliteWasmInNodeModules must run AFTER copySqliteAssetsToWebpackSource
// But wait, dependsOn doesn't guarantee order.
// We need to configure the tasks themselves.
finalizedBy(copySqliteAssetsToDist)
}
// Configure task ordering
patchSqliteWasmInNodeModules {
mustRunAfter(copySqliteAssetsToWebpackSource)
// Removed circular dependency: mustRunAfter(copySqliteAssetsToDist)
}
// Ensure the assets are present for the development bundle.
// Ensure the worker is present for the development bundle.
tasks.named("jsBrowserDevelopmentWebpack") {
dependsOn(copySqliteAssetsToWebpackSource)
dependsOn(patchSqliteWasmInNodeModules)
dependsOn(copySqliteWorkerToWebpackSource)
}
// Ensure the worker is present for the production bundle.
tasks.named("jsBrowserProductionWebpack") {
dependsOn(copySqliteWorkerToWebpackSource)
}
// KMP Compile-Optionen
@@ -253,9 +154,6 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
// Kotlin/JS source maps
// ---------------------------------------------------------------------------
// Production source maps must remain enabled for browser debugging.
// The remaining Kotlin/Gradle message
// `Cannot rewrite paths in JavaScript source maps: Too many sources or format is not supported`
// is treated as an external Kotlin/JS toolchain limitation and is documented separately.
// Configure a duplicate handling strategy for distribution tasks
tasks.withType<Tar> {
@@ -268,7 +166,7 @@ tasks.withType<Zip> {
// Duplicate-Handling für Distribution
tasks.withType<Copy> {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE // Statt EXCLUDE
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.withType<Sync> {
@@ -39,6 +39,7 @@ fun MainApp() {
onPrimaryCta = { currentScreen = AppScreen.Login },
onSecondary = { currentScreen = AppScreen.Home }
)
is AppScreen.Home -> WelcomeScreen(
authTokenManager = authTokenManager,
onOpenPing = { currentScreen = AppScreen.Ping },
@@ -169,7 +170,7 @@ private fun LandingScreen(
@Composable
private fun FeatureCard(number: String, title: String, body: String) {
Surface( tonalElevation = 0.dp ) {
Surface(tonalElevation = 0.dp) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.width(56.dp).padding(top = 6.dp)) {
Text(text = number, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
@@ -1,14 +0,0 @@
// This is a dummy file to satisfy Webpack's requirement for sqlite3.wasm and other modules.
// It mimics the structure of the sqlite3-wasm module to prevent build errors.
// The worker code imports it like this:
// import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
// So we need a default export that is a function.
// This function should mimic the behavior of sqlite3InitModule, which returns a Promise.
export default function dummySqlite3InitModule() {
// Since we are manually loading the WASM binary in the worker, this dummy module
// is primarily here to satisfy Webpack's resolution and prevent errors.
// It doesn't need to actually load the WASM.
return Promise.resolve({});
};
@@ -15,7 +15,7 @@
<script>
// Prefer explicit query param override (?apiBaseUrl=http://host:port),
// then fall back to same-origin. This avoids Docker secrets and works with Nginx proxy.
(function(){
(function () {
try {
const params = new URLSearchParams(window.location.search);
const override = params.get('apiBaseUrl');
@@ -29,13 +29,13 @@
}
})();
// KMP bundle will read globalThis.API_BASE_URL in PlatformConfig.js
</script>
</script>
<script src="web-app.js"></script>
<script>
// Register Service Worker only in non-localhost environments
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err){
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function (err) {
console.warn('ServiceWorker registration failed:', err);
});
});
@@ -0,0 +1,15 @@
// Dummy module to satisfy WASI imports in Webpack
// Used for skiko.wasm and potentially others
export function abort() {
console.error("WASI abort called");
}
// Some WASM modules might look for these
export function emscripten_notify_memory_growth() {
}
export default {
abort,
emscripten_notify_memory_growth
};
@@ -1,29 +0,0 @@
// Suppress a known, external webpack warning coming from `@sqlite.org/sqlite-wasm`.
//
// Webpack warning:
// "Critical dependency: the request of a dependency is an expression"
//
// Root cause:
// `@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.mjs` uses a dynamic Worker URL:
// `new Worker(new URL(options.proxyUri, import.meta.url))`
// which webpack cannot statically analyze.
//
// We keep this suppression максимально spezifisch:
// - match only this warning message
// - and only if it originates from the sqlite-wasm package path.
(function (config) {
config.ignoreWarnings = config.ignoreWarnings || []
// Webpack passes warning objects with `message` and `module.resource`.
config.ignoreWarnings.push((warning) => {
const message = String(warning && warning.message ? warning.message : warning)
if (!message.includes('Critical dependency: the request of a dependency is an expression')) return false
const resource = warning && warning.module && warning.module.resource
? String(warning.module.resource)
: ''
return resource.includes('node_modules/@sqlite.org/sqlite-wasm/')
})
})(config)
@@ -1,104 +0,0 @@
// This file contains Webpack configuration adjustments for WebAssembly modules,
// specifically to handle `skiko.wasm` and `sqlite3.wasm` correctly.
var pathModule;
try {
pathModule = path;
} catch (e) {
pathModule = require('path');
}
var webpackModule;
try {
webpackModule = webpack;
} catch (e) {
webpackModule = require('webpack');
}
// 1. Enable WebAssembly experiments in Webpack 5
config.experiments = config.experiments || {};
config.experiments.asyncWebAssembly = true;
config.module = config.module || {};
config.module.rules = config.module.rules || [];
// 2. Add a rule to correctly handle .wasm files (like skiko.wasm) as WebAssembly modules
config.module.rules.push({
test: /\.wasm$/,
type: 'webassembly/async'
});
// 3. NormalModuleReplacementPlugin to redirect 'sqlite3.wasm' AND other internal sqlite-wasm modules to our dummy JS file.
// This is needed because the `sqlite-wasm` library tries to `require` these files in a Webpack environment.
// We want these `require` calls to return an empty JS object (from dummy.js) instead of failing.
// Our worker will manually fetch the real sqlite3.wasm.
const dummyPath = pathModule.resolve(__dirname, "../../../../frontend/shells/meldestelle-portal/build/processedResources/js/main/dummy.js");
// Redirect sqlite3.wasm
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3\.wasm$/,
dummyPath
)
);
// Redirect other internal sqlite-wasm modules that might be causing issues
// The error log showed: Can't resolve './sqlite-wasm/jswasm/sqlite3.mjs' and 'sqlite3-worker1-promiser.mjs'
// We redirect them to dummy.js as well, assuming we don't need them for our manual loading approach.
// Be careful not to redirect the main entry point if it's needed.
// The errors seem to come from inside the node_modules package trying to resolve relative paths.
// Let's try to be more specific. If these are optional dependencies or part of the node-loading logic,
// replacing them with dummy.js should be fine.
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3\.mjs$/,
function(resource) {
// Only replace if it's inside the sqlite-wasm package structure we want to avoid
if (resource.context.includes('@sqlite.org/sqlite-wasm')) {
resource.request = dummyPath;
}
}
)
);
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3-worker1-promiser\.mjs$/,
dummyPath
)
);
// 4. Handle WASI imports for skiko.wasm (env, wasi_snapshot_preview1)
// Webpack needs to know how to resolve these "magic" imports.
// We can treat them as externals or empty modules.
// Since we are in a browser environment, these are often provided by the runtime or polyfilled.
// Mapping them to false tells Webpack to ignore them (empty module).
config.resolve = config.resolve || {};
config.resolve.fallback = config.resolve.fallback || {};
// Fallbacks for Node.js core modules that might be required by libraries
config.resolve.fallback.fs = false;
config.resolve.fallback.path = false;
config.resolve.fallback.crypto = false;
// Ignore WASI imports
config.ignoreWarnings = config.ignoreWarnings || [];
config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/);
// Use externals to handle WASI imports if fallback doesn't work
config.externals = config.externals || {};
// config.externals['env'] = 'env'; // This might expect a global 'env' variable
// config.externals['wasi_snapshot_preview1'] = 'wasi_snapshot_preview1';
// Better approach for WASI in Webpack 5 with asyncWebAssembly:
// Webpack should handle this if we don't interfere.
// The error "Can't resolve 'env'" suggests it's looking for a module named 'env'.
// We can provide a dummy module for these.
config.resolve.alias = config.resolve.alias || {};
config.resolve.alias['env'] = dummyPath;
config.resolve.alias['wasi_snapshot_preview1'] = dummyPath;
@@ -3,6 +3,6 @@
config.devServer = config.devServer || {};
config.devServer.headers = {
...config.devServer.headers,
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
};
@@ -0,0 +1,93 @@
// Webpack configuration for SQLite WASM support AND Skiko fixes
const CopyWebpackPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
const path = require('path');
const fs = require('fs');
config.resolve = config.resolve || {};
config.resolve.fallback = config.resolve.fallback || {};
config.resolve.alias = config.resolve.alias || {};
// 1. Fallbacks for Node.js core modules
config.resolve.fallback.fs = false;
config.resolve.fallback.path = false;
config.resolve.fallback.crypto = false;
// 2. Resolve sqlite3 paths
let sqliteBaseDir;
try {
const packagePath = path.dirname(require.resolve('@sqlite.org/sqlite-wasm/package.json'));
sqliteBaseDir = path.join(packagePath, 'sqlite-wasm/jswasm');
} catch (e) {
console.warn("Could not resolve @sqlite.org/sqlite-wasm path automatically. Using fallback path.");
sqliteBaseDir = path.resolve(__dirname, '../../../../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm');
}
// 3. Copy ALL sqlite3 assets (wasm, js, and auxiliary workers)
if (fs.existsSync(sqliteBaseDir)) {
console.log("Copying sqlite3 assets from:", sqliteBaseDir);
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: sqliteBaseDir,
to: '.', // Copy to root of dist
globOptions: {
ignore: ['**/package.json'] // Don't copy package.json if present
},
noErrorOnMissing: true
}
]
})
);
} else {
console.error("ERROR: sqlite3 base directory does not exist:", sqliteBaseDir);
}
// 4. Alias sqlite3.wasm (still needed for some internal checks maybe)
const sqliteWasmPath = path.join(sqliteBaseDir, 'sqlite3.wasm');
config.resolve.alias['sqlite3.wasm'] = sqliteWasmPath;
config.resolve.alias['./sqlite3.wasm'] = sqliteWasmPath;
// 5. Handle .wasm files
config.experiments = config.experiments || {};
config.experiments.asyncWebAssembly = true;
config.module = config.module || {};
config.module.rules = config.module.rules || [];
// Treat Skiko WASM as resource to avoid parsing errors
config.module.rules.push({
test: /skiko\.wasm$/,
type: 'asset/resource'
});
// Treat other WASM as async (default)
config.module.rules.push({
test: /\.wasm$/,
exclude: /skiko\.wasm$/,
type: 'webassembly/async'
});
// 6. Ignore warnings
config.ignoreWarnings = config.ignoreWarnings || [];
config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/);
// 7. Fix for "webpackEmptyContext" in sqlite3.mjs
config.plugins.push(
new webpack.ContextReplacementPlugin(
/@sqlite\.org\/sqlite-wasm/,
(data) => {
delete data.dependencies;
return data;
}
)
);
// 8. MIME types
config.devServer = config.devServer || {};
config.devServer.devMiddleware = config.devServer.devMiddleware || {};
config.devServer.devMiddleware.mimeTypes = {
'application/wasm': ['wasm'],
'application/javascript': ['js']
};
@@ -1,9 +0,0 @@
// Fix für sql.js unter Webpack 5
config.resolve = config.resolve || {};
config.resolve.fallback = config.resolve.fallback || {};
config.resolve.fallback.fs = false;
config.resolve.fallback.path = false;
config.resolve.fallback.crypto = false;
config.resolve.fallback.os = false;
config.resolve.fallback.stream = false;
config.resolve.fallback.buffer = false;
+67 -61
View File
@@ -108,6 +108,11 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
# Web Specifics
#npm-sqljs = { module = "sql.js", version = "1.12.0" }
#npm-copy-webpack = { module = "copy-webpack-plugin", version = "12.0.0" }
# ==============================================================================
# === FRONTEND: COMPOSE UI ===
# ==============================================================================
@@ -143,6 +148,7 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver
# ==============================================================================
# === FRONTEND: LOCAL PERSISTENCE (SQLDelight) ===
# ==============================================================================
sqldelight-gradle = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } # JVM
@@ -269,99 +275,99 @@ kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
# === FRONTEND BUNDLES ===
# Use these in commonMain to reduce boilerplate
kmp-common = [
"kotlinx-coroutines-core",
"kotlinx-serialization-json",
"kotlinx-datetime"
"kotlinx-coroutines-core",
"kotlinx-serialization-json",
"kotlinx-datetime"
]
ktor-client-common = [
"ktor-client-core",
"ktor-client-contentNegotiation",
"ktor-client-serialization-kotlinx-json",
"ktor-client-auth",
"ktor-client-logging"
"ktor-client-core",
"ktor-client-contentNegotiation",
"ktor-client-serialization-kotlinx-json",
"ktor-client-auth",
"ktor-client-logging"
]
compose-common = [
"androidx-lifecycle-viewmodelCompose",
"androidx-lifecycle-runtimeCompose"
"androidx-lifecycle-viewmodelCompose",
"androidx-lifecycle-runtimeCompose"
]
# === BACKEND BUNDLES ===
testing-jvm = [
"junit-jupiter-api",
"junit-jupiter-engine",
"junit-jupiter-params",
"junit-platform-launcher",
"mockk",
"assertj-core"
"junit-jupiter-api",
"junit-jupiter-engine",
"junit-jupiter-params",
"junit-platform-launcher",
"mockk",
"assertj-core"
]
test-spring = [
"spring-boot-starter-test",
"spring-security-test"
"spring-boot-starter-test",
"spring-security-test"
]
spring-boot-service-complete = [
"spring-boot-starter-web",
"spring-boot-starter-validation",
"spring-boot-starter-actuator",
"spring-boot-starter-json",
"spring-boot-starter-aop",
"jackson-module-kotlin",
"jackson-datatype-jsr310",
"micrometer-prometheus",
"micrometer-tracing-bridge-brave",
"zipkin-reporter-brave",
"zipkin-sender-okhttp3"
"spring-boot-starter-web",
"spring-boot-starter-validation",
"spring-boot-starter-actuator",
"spring-boot-starter-json",
"spring-boot-starter-aop",
"jackson-module-kotlin",
"jackson-datatype-jsr310",
"micrometer-prometheus",
"micrometer-tracing-bridge-brave",
"zipkin-reporter-brave",
"zipkin-sender-okhttp3"
]
# Standard dependencies for a "secure" Spring Boot microservice (architecture-approved baseline)
spring-boot-secure-service = [
"spring-boot-starter-web",
"spring-boot-starter-actuator",
"spring-boot-starter-security",
"spring-boot-starter-oauth2-resource-server",
"spring-cloud-starter-consul-discovery"
"spring-boot-starter-web",
"spring-boot-starter-actuator",
"spring-boot-starter-security",
"spring-boot-starter-oauth2-resource-server",
"spring-cloud-starter-consul-discovery"
]
database-complete = [
"spring-boot-starter-data-jpa",
"postgresql-driver",
"hikari-cp",
"flyway-core",
"flyway-postgresql"
"spring-boot-starter-data-jpa",
"postgresql-driver",
"hikari-cp",
"flyway-core",
"flyway-postgresql"
]
redis-cache = [
"spring-boot-starter-data-redis",
"lettuce-core",
"jackson-module-kotlin",
"jackson-datatype-jsr310"
"spring-boot-starter-data-redis",
"lettuce-core",
"jackson-module-kotlin",
"jackson-datatype-jsr310"
]
testcontainers = [
"testcontainers-core",
"testcontainers-junit-jupiter",
"testcontainers-postgresql",
"testcontainers-kafka",
"testcontainers-keycloak"
"testcontainers-core",
"testcontainers-junit-jupiter",
"testcontainers-postgresql",
"testcontainers-kafka",
"testcontainers-keycloak"
]
kafka-config = [
"spring-kafka",
"reactor-kafka",
"jackson-module-kotlin",
"jackson-datatype-jsr310"
"spring-kafka",
"reactor-kafka",
"jackson-module-kotlin",
"jackson-datatype-jsr310"
]
monitoring-client = [
"spring-boot-starter-actuator",
"micrometer-prometheus",
"micrometer-tracing-bridge-brave",
"zipkin-reporter-brave",
"zipkin-sender-okhttp3"
"spring-boot-starter-actuator",
"micrometer-prometheus",
"micrometer-tracing-bridge-brave",
"zipkin-reporter-brave",
"zipkin-sender-okhttp3"
]
jackson-kotlin = [
"jackson-module-kotlin",
"jackson-datatype-jsr310"
"jackson-module-kotlin",
"jackson-datatype-jsr310"
]
resilience = [
"resilience4j-spring-boot3",
"resilience4j-reactor"
"resilience4j-spring-boot3",
"resilience4j-reactor"
]
[plugins]
+55 -1
View File
@@ -116,6 +116,11 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@sindresorhus/merge-streams@^2.1.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958"
integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==
"@sqlite.org/sqlite-wasm@3.51.1-build2":
version "3.51.1-build2"
resolved "https://registry.yarnpkg.com/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.51.1-build2.tgz#822497fdd05cbee3b1e8209aa46ffe1bafc70dbb"
@@ -700,6 +705,18 @@ copy-webpack-plugin@11.0.0:
schema-utils "^4.0.0"
serialize-javascript "^6.0.0"
copy-webpack-plugin@12.0.0:
version "12.0.0"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-12.0.0.tgz#8694c97dec19a05c9c93e7aebb93a976699dcd0e"
integrity sha512-WQfxZKgoRB94/g0BP/cjADvE6jXQKoSk0lFFeIC5HZlE1JuabYPwhQUtOZsGI0cI+QMsi6vhdIDy4eNXKcTgFg==
dependencies:
fast-glob "^3.3.2"
glob-parent "^6.0.1"
globby "^14.0.0"
normalize-path "^3.0.0"
schema-utils "^4.2.0"
serialize-javascript "^6.0.2"
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
@@ -936,7 +953,7 @@ fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.2.11, fast-glob@^3.3.0:
fast-glob@^3.2.11, fast-glob@^3.3.0, fast-glob@^3.3.2, fast-glob@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
@@ -1093,6 +1110,18 @@ globby@^13.1.1:
merge2 "^1.4.1"
slash "^4.0.0"
globby@^14.0.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-14.1.0.tgz#138b78e77cf5a8d794e327b15dce80bf1fb0a73e"
integrity sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==
dependencies:
"@sindresorhus/merge-streams" "^2.1.0"
fast-glob "^3.3.3"
ignore "^7.0.3"
path-type "^6.0.0"
slash "^5.1.0"
unicorn-magic "^0.3.0"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@@ -1210,6 +1239,11 @@ ignore@^5.2.4:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
ignore@^7.0.3:
version "7.0.5"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
import-local@^3.0.2:
version "3.2.0"
resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260"
@@ -1598,6 +1632,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
path-type@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-6.0.0.tgz#2f1bb6791a91ce99194caede5d6c5920ed81eb51"
integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -1917,6 +1956,11 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
slash@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce"
integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==
sockjs@^0.3.24:
version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
@@ -2077,11 +2121,21 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@5.9.2:
version "5.9.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
undici-types@~7.10.0:
version "7.10.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
unicorn-magic@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104"
integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"