Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e4f7de3
Core: one ServerRequest effect and one ServerResponse command own the…
JacobStephens2 Oct 3, 2026
ac64b1d
Address review: ADR superseded markers, StatusClass, ledger awaits
JacobStephens2 Oct 3, 2026
40f6e7b
Merge pull request #63 from JacobStephens2/issue-58
JacobStephens2 Oct 3, 2026
10acf75
Apple shell: carry every server request through one HTTP adapter
JacobStephens2 Oct 3, 2026
cd8be8c
Address review: SyncConfig in its own file, status-0 cases said once
JacobStephens2 Oct 3, 2026
413819a
Android: carry every server request through one HTTP adapter
JacobStephens2 Oct 3, 2026
b779cc8
Web: carry every server request through one HTTP adapter
JacobStephens2 Oct 3, 2026
a753c49
Address review: platform comment follows the core's wording
JacobStephens2 Oct 3, 2026
fedbcb6
Address review: drop the shell test harness, settle unstarted requests
JacobStephens2 Oct 3, 2026
1c1bd19
Merge pull request #66 from JacobStephens2/issue-59
JacobStephens2 Oct 3, 2026
f0a447d
Windows shell: carry every server request through one HTTP adapter
JacobStephens2 Oct 3, 2026
787ebb1
Merge remote-tracking branch 'origin/issue-57' into issue-60
JacobStephens2 Oct 3, 2026
404779e
Merge remote-tracking branch 'origin/issue-57' into issue-61
JacobStephens2 Oct 3, 2026
a0ae3d8
Address review: name the view model's carry-and-settle step
JacobStephens2 Oct 3, 2026
8f0d376
Merge remote-tracking branch 'origin/issue-57' into issue-62
JacobStephens2 Oct 3, 2026
b60b425
Merge pull request #67 from JacobStephens2/issue-60
JacobStephens2 Oct 3, 2026
3700065
Merge remote-tracking branch 'origin/issue-57' into issue-61
JacobStephens2 Oct 3, 2026
07b668a
Merge remote-tracking branch 'origin/issue-57' into issue-62
JacobStephens2 Oct 3, 2026
b4d7f51
Merge pull request #65 from JacobStephens2/issue-61
JacobStephens2 Oct 3, 2026
7186788
Merge remote-tracking branch 'origin/issue-57' into issue-62
JacobStephens2 Oct 3, 2026
e3c2a47
Merge pull request #68 from JacobStephens2/issue-62
JacobStephens2 Oct 3, 2026
199b97f
Address review: verify parses to a Session, push asserts its reserved id
JacobStephens2 Oct 3, 2026
b707276
Address review: shell wording and wire shape follow the core
JacobStephens2 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ class CascadeBridgeHolder(
}
private val dispatchLock = Any()

/** Request effects from every dispatch, in the order the core produced
/** Server requests from every dispatch, in the order the core produced
* them, for the one request carrier (SyncManager). A channel rather than a
* StateFlow, so none is overwritten or dropped: each is received, and so
* settled, exactly once — whether a tick, a playback report, a UI command
* or a settle produced it. */
private val _requests = Channel<RequestEffect>(Channel.UNLIMITED)
val requests: ReceiveChannel<RequestEffect> = _requests
private val _requests = Channel<Effect.ServerRequest>(Channel.UNLIMITED)
val requests: ReceiveChannel<Effect.ServerRequest> = _requests

private val bridge = CascadeBridge()

Expand Down Expand Up @@ -100,7 +100,7 @@ class CascadeBridgeHolder(
is Effect.PersistSettings -> writes.trySend { settingsStore.write(effect.json) }
is Effect.PersistListening -> writes.trySend { settingsStore.writeListening(effect.json) }
is Effect.PersistAccount -> writes.trySend { accountStore.write(effect.json) }
is RequestEffect -> _requests.trySend(effect)
is Effect.ServerRequest -> _requests.trySend(effect)
else -> {}
}
}
Expand All @@ -115,7 +115,7 @@ class CascadeBridgeHolder(
private fun forHandlers(effects: List<Effect>): List<Effect> = effects.filter {
when (it) {
is Effect.PersistSettings, is Effect.PersistListening, is Effect.PersistAccount -> false
is RequestEffect -> false
is Effect.ServerRequest -> false
else -> true
}
}
Expand Down
53 changes: 28 additions & 25 deletions apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package page.stephens.cascade.core

import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
Expand Down Expand Up @@ -43,19 +44,26 @@ sealed class Command {
val accountJson: String,
) : Command()
@Serializable @SerialName("beginListeningSync") data class BeginListeningSync(val reason: SyncReason) : Command()
@Serializable @SerialName("listeningSyncSucceeded") data class ListeningSyncSucceeded(val serverTotalMs: Long) : Command()
@Serializable @SerialName("listeningSyncFailed") data class ListeningSyncFailed(val unauthorized: Boolean) : Command()
@Serializable @SerialName("resetListeningData") data class ResetListeningData(val newDeviceId: String) : Command()
@Serializable @SerialName("requestSignInLink") data class RequestSignInLink(val email: String) : Command()
/** [platform] names the platform whose app the emailed link should hand off
* to; omitted from the wire when null. Android sends none. */
@Serializable @SerialName("requestSignInLink") data class RequestSignInLink(
val email: String,
@EncodeDefault(EncodeDefault.Mode.NEVER) val platform: String? = null,
) : Command()
@Serializable @SerialName("submitSignInLink") data class SubmitSignInLink(val input: String) : Command()
@Serializable @SerialName("signOut") data object SignOut : Command()
@Serializable @SerialName("deleteListeningData") data object DeleteListeningData : Command()
@Serializable @SerialName("deleteAccount") data object DeleteAccount : Command()
@Serializable @SerialName("signInLinkSent") data object SignInLinkSent : Command()
@Serializable @SerialName("signInVerified") data class SignInVerified(val sessionToken: String, val email: String) : Command()
@Serializable @SerialName("listeningDataDeleted") data class ListeningDataDeleted(val newDeviceId: String) : Command()
@Serializable @SerialName("accountDeleted") data class AccountDeleted(val newDeviceId: String) : Command()
@Serializable @SerialName("accountRequestFailed") data class AccountRequestFailed(val unauthorized: Boolean) : Command()
/** [newDeviceId] is a fresh random id, adopted only once the server confirms. */
@Serializable @SerialName("deleteListeningData") data class DeleteListeningData(val newDeviceId: String) : Command()
/** [newDeviceId] is a fresh random id, adopted only once the server confirms. */
@Serializable @SerialName("deleteAccount") data class DeleteAccount(val newDeviceId: String) : Command()
/** Settles the [Effect.ServerRequest] with this [id]: the HTTP [status] (0
* when not sent or no response) and the response [body] verbatim. */
@Serializable @SerialName("serverResponse") data class ServerResponse(
val id: Long,
val status: Int,
val body: String,
) : Command()
}

/** Why the shell is asking to sync; the core decides whether it's worth a PUT.
Expand All @@ -66,10 +74,6 @@ enum class SyncReason {
@SerialName("refresh") REFRESH,
}

/** An HTTP request the shell's one carrier runs and settles. Sealed, so the
* carrier's `when` is exhaustive: a new request can't be silently dropped. */
sealed interface RequestEffect

@Serializable
@JsonClassDiscriminator("type")
sealed class Effect {
Expand All @@ -79,17 +83,16 @@ sealed class Effect {
@Serializable @SerialName("setPlatformVolume") data class SetPlatformVolume(val gain: Float) : Effect()
@Serializable @SerialName("persistSettings") data class PersistSettings(val json: String) : Effect()
@Serializable @SerialName("persistListening") data class PersistListening(val json: String) : Effect()
@Serializable @SerialName("pushListening") data class PushListening(
val deviceId: String,
val deviceTotalMs: Long,
val sessionToken: String,
) : Effect(), RequestEffect
@Serializable @SerialName("sendSignInLink") data class SendSignInLink(val email: String) : Effect(), RequestEffect
@Serializable @SerialName("verifySignInToken") data class VerifySignInToken(val token: String) : Effect(), RequestEffect
/** Fire-and-forget: no settle command. */
@Serializable @SerialName("revokeSession") data class RevokeSession(val sessionToken: String) : Effect(), RequestEffect
@Serializable @SerialName("deleteServerListening") data class DeleteServerListening(val sessionToken: String) : Effect(), RequestEffect
@Serializable @SerialName("deleteServerAccount") data class DeleteServerAccount(val sessionToken: String) : Effect(), RequestEffect
/** One request to the sync server: [method] to the base URL plus [path],
* with `Authorization: Bearer` when [bearerToken] is set and [body] sent
* verbatim as JSON when set. Always settled with [Command.ServerResponse]. */
@Serializable @SerialName("serverRequest") data class ServerRequest(
val id: Long,
val method: String,
val path: String,
val bearerToken: String? = null,
val body: String? = null,
) : Effect()
/** Store verbatim; `""` means delete the stored account. */
@Serializable @SerialName("persistAccount") data class PersistAccount(val json: String) : Effect()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package page.stephens.cascade.sync

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import page.stephens.cascade.core.Command
import page.stephens.cascade.core.Effect
import java.net.HttpURLConnection
import java.net.URL
import kotlin.coroutines.cancellation.CancellationException

/** Base URL of cascade-sync-server. Empty disables the account/sync feature. */
const val SYNC_API_BASE = "https://sync.cascade.stephens.page"

val syncAvailable: Boolean get() = SYNC_API_BASE.isNotEmpty()

/**
* The one HTTP adapter for the core's sync-server requests, dependency-free
* (HttpURLConnection on the IO dispatcher). It sends any
* [Effect.ServerRequest] exactly as described and reports the status and body
* verbatim; the core owns the request table and reads the response. Status 0
* means no response: no sync server ([baseUrl] empty, nothing sent), a network
* error or a timeout.
*/
class ServerHttp(
private val baseUrl: String,
private val timeoutMs: Int = 10_000,
) {
/**
* Send [request] in [scope] and hand its [Command.ServerResponse] to
* [settle], exactly once — including when the request is cancelled,
* before it starts or mid-flight, which settles with status 0. Until it
* is settled the core holds the next account request or listening sync.
*/
fun carry(
request: Effect.ServerRequest,
scope: CoroutineScope,
settle: (Command.ServerResponse) -> Unit,
): Job = scope.launch(start = CoroutineStart.ATOMIC) {
var response = unanswered(request)
try {
response = send(request)
} finally {
settle(response)
}
}

private suspend fun send(request: Effect.ServerRequest): Command.ServerResponse {
if (baseUrl.isEmpty()) return unanswered(request)
return withContext(Dispatchers.IO) {
try {
exchange(request)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
unanswered(request)
}
}
}

private fun exchange(request: Effect.ServerRequest): Command.ServerResponse {
val conn = (URL(baseUrl + request.path).openConnection() as HttpURLConnection).apply {
requestMethod = request.method
connectTimeout = timeoutMs
readTimeout = timeoutMs
request.bearerToken?.let { setRequestProperty("Authorization", "Bearer $it") }
}
try {
request.body?.let { body ->
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
}
val status = conn.responseCode
val stream = if (status in 200..299) conn.inputStream else conn.errorStream
val responseBody = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
return Command.ServerResponse(request.id, status, responseBody)
} finally {
conn.disconnect()
}
}

private fun unanswered(request: Effect.ServerRequest) =
Command.ServerResponse(request.id, status = 0, body = "")
}
106 changes: 0 additions & 106 deletions apps/android/app/src/main/java/page/stephens/cascade/sync/SyncApi.kt

This file was deleted.

Loading
Loading