Bind Run now, both evictions, and the library build on both modalities
Build Debug APK / build (push) Successful in 5m21s

The last controls anywhere that showed the click rather than the job. Each
already started one with a nameable (kind, subject); each kept a boolean or a
Pending variant beside it that only its own screen could see.

Settings' "Run now" reads BackgroundSync/None; "Delete Locally" reads
Eviction/Document and "Reclaim…" Eviction/DocumentType — distinct subjects on
purpose, so a subtree eviction and a type-scoped reclaim neither dedup against
nor mask each other. Music's "Build library" reads LibraryBuild/DocumentType.
Both keep Evaluating, which is genuinely the screen's: gathering the evidence
for the confirmation dialog is not work another node can see.

FFI: JobSubjectRef gains None, since "no per-thing widget speaks for whole-node
work" stopped being true the moment Run now bound to it, and its DocumentType
variant now carries the enum rather than a snake_case name — a frontend that
had to produce that name would be a mirror of DocumentType::type_name, which
is what job_match_key exists to avoid. Both are covered by the drift guard in
free_fns' tests.

Also fixes rust/tui-app's EnsembleStatus test fixture, which had not been
updated for the three epoch fields and was failing the workspace build; the
values are the zeros every other field in that fixture already uses.
This commit is contained in:
Greg Shuflin
2026-08-28 00:03:49 -07:00
parent f90be1c5b6
commit 83db04ee8a
10 changed files with 174 additions and 78 deletions
+6 -10
View File
@@ -456,16 +456,12 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
one `ByteArray`.
- The Jobs screen should be aware of when a long-running sync was initiated by another node, or syncs
that were safely/temporarily interrupted because a device went off wifi etc.
- **Half the job-starting controls are still screen-local flags, and the same half on both
modalities.** The document detail and Ensemble screens' pull/push/sync buttons and the
Documents screen's imports are bound — see `docs/sync-management.md`'s "Widgets reflect job
state in place". Everything else still shows the *click*: Settings' "Run now"
(`BackgroundSync`/`None` on both `settings/sync.rs`'s `RunNowState` and
`SyncSettingsScreen.kt`'s `runNowPending`), "Delete Locally" and "Reclaim…"
(`Eviction`/`Document` and `Eviction`/`DocumentType`), and the music library build
(`LibraryBuild`/`DocumentType`). Each already starts a job with a nameable `(kind, subject)`,
so each is a small change; what makes them worth doing is that all four are exactly the work
the scheduler or another node can start behind the user's back.
- **Purge is the one long-running action that is not a Job at all.** Every other control on the
document detail screen starts one; "Purge from ensemble…" writes tombstones and broadcasts a
delete intent behind a plain `PurgeState::Pending` boolean, on both modalities. `JobKind::Purge`
exists and nothing constructs it. Until it does there is nothing for the button to bind to, no
Jobs-screen row for a purge that is still propagating, and no dedup against a second click on
another screen — so this is a `run_as_job` wrap first, and a widget binding after.
- **Android's media library screens still show nothing while an import runs**, where the
desktop's now carry `components::imports_in_progress`. Comics/PDF/Pictures/Music import one
file at a time and do report progress into a job row, so there is something to bind to, but
@@ -308,7 +308,9 @@ fun DocumentDetailScreen(documentId: String, onBack: () -> Unit, modifier: Modif
// Execute the eviction plan currently shown in the confirmation dialog. The buried
// Settings override is re-checked at execute time, not trusted from the click.
fun confirmDeleteLocally(allowUnverified: Boolean) {
deleteLocallyState = EvictionFlowState.Pending
// Nothing here says "running" — the `Eviction` job does, and the Danger Zone reads it.
// This only takes the dialog down.
deleteLocallyState = EvictionFlowState.Idle
scope.launch {
val result =
withContext(Dispatchers.IO) {
@@ -409,6 +411,7 @@ fun DocumentDetailScreen(documentId: String, onBack: () -> Unit, modifier: Modif
}
}
},
documentId = documentId,
deleteLocallyState = deleteLocallyState,
onDeleteLocallyClick = { openDeleteLocallyConfirm() },
)
@@ -881,12 +884,19 @@ private fun RemoteOverrideControl(
@Composable
private fun DangerZoneCard(
documentId: String,
purging: Boolean,
error: String?,
onPurgeClick: () -> Unit,
deleteLocallyState: EvictionFlowState?,
onDeleteLocallyClick: () -> Unit,
) {
// The eviction itself is a job, so it reports whoever started it — this screen before it was
// backgrounded, or the Sync policy screen's type-scoped "Reclaim…" — and survives leaving and
// re-entering. `Evaluating` stays this screen's: gathering the evidence for the dialog is not
// work anyone else can see. `JobSubjectRef.Document`, not `DocumentType`: this is one subtree.
val evicting =
rememberJobControl(JobKinds.EVICTION, JobSubjectRef.Document(documentId = documentId))
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
@@ -919,24 +929,23 @@ private fun DangerZoneCard(
Text("Purge")
}
}
val deleteLocallyBusy =
deleteLocallyState is EvictionFlowState.Evaluating ||
deleteLocallyState is EvictionFlowState.Pending
OutlinedButton(onClick = onDeleteLocallyClick, enabled = !deleteLocallyBusy) {
if (deleteLocallyBusy) {
val evaluating = deleteLocallyState is EvictionFlowState.Evaluating
OutlinedButton(
onClick = onDeleteLocallyClick,
enabled = !evaluating && !evicting.busy,
) {
if (evaluating) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
if (deleteLocallyState is EvictionFlowState.Evaluating) "Checking…"
else "Deleting…"
)
Text("Checking…")
} else {
Text("Delete Locally")
}
}
JobActivity(control = evicting, fallback = "Deleting…")
}
if (!error.isNullOrEmpty()) {
Text(
@@ -945,16 +954,18 @@ private fun DangerZoneCard(
color = MaterialTheme.colorScheme.error,
)
}
when (deleteLocallyState) {
// Hidden while the job runs, so a finished deletion's summary can't be read as this
// one's.
when (val outcome = if (evicting.busy) null else deleteLocallyState) {
is EvictionFlowState.Done ->
Text(
text = deleteLocallyState.message,
text = outcome.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
is EvictionFlowState.Failed ->
Text(
text = "Delete locally failed: ${deleteLocallyState.message}",
text = "Delete locally failed: ${outcome.message}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
@@ -38,9 +38,12 @@ private const val PERCENT_SCALE = 100f
* comment on `job_match_key`.
*/
object JobKinds {
const val BACKGROUND_SYNC = "background_sync"
const val CATALOGUE_SYNC = "catalogue_sync"
const val CONTENT_FETCH = "content_fetch"
const val EVICTION = "eviction"
const val IMPORT = "import"
const val LIBRARY_BUILD = "library_build"
const val PUSH = "push"
}
@@ -30,6 +30,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.gregshuflin.synchronicity.NativeLib
import com.gregshuflin.synchronicity.OnStoreChange
import com.gregshuflin.synchronicity.jobs.JobKinds
import com.gregshuflin.synchronicity.jobs.rememberJobControl
import com.gregshuflin.synchronicity.podcast.AudioPlayerProvider
import com.gregshuflin.synchronicity.podcast.PlaybackSource
import com.gregshuflin.synchronicity.ui.Adjacency
@@ -48,6 +50,8 @@ import com.gregshuflin.synchronicity.ui.rememberLibraryEmptyCopy
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import uniffi.synchronicity.ChangeChannel
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.JobSubjectRef
import uniffi.synchronicity.LibraryEntry
import uniffi.synchronicity.LibraryEntryKind
import uniffi.synchronicity.MusicTreeItem
@@ -134,7 +138,9 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
// song's Next means.
var playbackContext by remember { mutableStateOf<List<MusicTrack>>(emptyList()) }
var isLoadingAudio by remember { mutableStateOf(false) }
var building by remember { mutableStateOf(false) }
// Only the milliseconds between the tap and the `LibraryBuild` job row existing; after that
// the job is the whole truth. See `jobs/JobBinding.kt`.
var buildStarting by remember { mutableStateOf(false) }
var reloadKey by remember { mutableIntStateOf(0) }
// A document arriving from another node, or an import finishing here, belongs in the
// list on screen — not in the one the user will see next time they open this. The
@@ -215,15 +221,17 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
// pointing at what used to be there. Mirrors the desktop's own post-build reset.
fun rebuildLibrary() {
scope.launchReportingFailure(snackbar, TAG, "build the music library") {
building = true
buildStarting = true
// In the `finally` so a failure — including "a build is already running" — puts the
// action back rather than leaving a spinner that never resolves. The flag is set
// inside the block, not around it, because a retry re-enters this same block.
// inside the block, not around it, because a retry re-enters this same block. It
// covers only the gap before the `LibraryBuild` job row exists; after that the job is
// what the action reads.
val summary =
try {
repository.buildLibrary().bind()
} finally {
building = false
buildStarting = false
}
libraryPath = emptyList()
art.clear()
@@ -245,7 +253,9 @@ fun MusicScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
title = "Music",
onBack = onBack,
modifier = modifier,
actions = { RebuildLibraryAction(building = building, onRebuild = { rebuildLibrary() }) },
actions = {
RebuildLibraryAction(starting = buildStarting, onRebuild = { rebuildLibrary() })
},
search = {
SynSearchBar(
query = searchQuery,
@@ -366,12 +376,22 @@ internal fun adjacencyOf(track: MusicTrack?, context: List<MusicTrack>): Adjacen
/**
* The header's rebuild action: the Library tab is derived from the tracks' tags, and nothing
* derives it on its own, so this is where a phone-only node turns imported files into an
* Artist/Album view. The work is a tracked Job on the Rust side and the screen stays live while it
* runs; the spinner here only says that this screen started one.
* Artist/Album view.
*
* Bound to the `LibraryBuild` job for this agent's type rather than to a "did this screen tap it"
* flag, so a build started from the desktop's copy of this button — or by this screen before the
* app was last backgrounded — reads here as what it is. [starting] covers only the milliseconds
* before the job row exists.
*/
@Composable
private fun RebuildLibraryAction(building: Boolean, onRebuild: () -> Unit) {
if (building) {
private fun RebuildLibraryAction(starting: Boolean, onRebuild: () -> Unit) {
val control =
rememberJobControl(
JobKinds.LIBRARY_BUILD,
JobSubjectRef.DocumentType(DocumentType.MUSIC_TRACK),
starting = starting,
)
if (control.busy) {
CircularProgressIndicator(modifier = Modifier.size(REBUILD_SPINNER_SIZE))
} else {
IconButton(onClick = onRebuild) {
@@ -14,10 +14,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.gregshuflin.synchronicity.jobs.JobActivity
import com.gregshuflin.synchronicity.jobs.JobKinds
import com.gregshuflin.synchronicity.jobs.rememberJobControl
import com.gregshuflin.synchronicity.ui.EvictionFlowState
import uniffi.synchronicity.ContentRow
import uniffi.synchronicity.DocumentSyncPolicy
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.JobSubjectRef
import uniffi.synchronicity.RemoteDocumentSyncPolicy
import uniffi.synchronicity.SyncPolicy
import uniffi.synchronicity.SyncPolicyOption
@@ -56,6 +60,13 @@ fun SyncPolicyControl(
onReclaim: (() -> Unit)? = null,
reclaimStatus: EvictionFlowState? = null,
) {
// A running eviction for this type outranks whatever this screen's own flow last said, and is
// reported even when there is no flow at all: it may have been started from the document
// detail screen's "Delete Locally", or here before the screen was last backgrounded.
// `JobSubjectRef.DocumentType` rather than `Document`, so a type-scoped reclaim dedups against
// itself rather than colliding with a per-document eviction of something in that type.
val reclaiming =
rememberJobControl(JobKinds.EVICTION, JobSubjectRef.DocumentType(entry.docType))
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.displayName,
@@ -96,37 +107,34 @@ fun SyncPolicyControl(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
if (onReclaim != null && reclaimStatus == null) {
if (onReclaim != null && reclaimStatus == null && !reclaiming.busy) {
TextButton(onClick = onReclaim) { Text("Reclaim…") }
}
JobActivity(control = reclaiming, fallback = "Reclaiming…")
}
}
when (reclaimStatus) {
// Hidden while the job runs, so a finished reclaim's summary can't be read as this one's.
when (val outcome = if (reclaiming.busy) null else reclaimStatus) {
is EvictionFlowState.Evaluating ->
Text(
"Checking…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is EvictionFlowState.Pending ->
Text(
"Reclaiming…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is EvictionFlowState.Done ->
Text(
reclaimStatus.message,
outcome.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
is EvictionFlowState.Failed ->
Text(
"Reclaim failed: ${reclaimStatus.message}",
"Reclaim failed: ${outcome.message}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is EvictionFlowState.Confirming,
is EvictionFlowState.Idle,
null -> {}
}
}
@@ -32,6 +32,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.gregshuflin.synchronicity.NativeLib
import com.gregshuflin.synchronicity.OnStoreChange
import com.gregshuflin.synchronicity.jobs.JobActivity
import com.gregshuflin.synchronicity.jobs.JobKinds
import com.gregshuflin.synchronicity.jobs.rememberJobControl
import com.gregshuflin.synchronicity.scheduler.SchedulerWorker
import com.gregshuflin.synchronicity.ui.EvictionConfirmDialog
import com.gregshuflin.synchronicity.ui.EvictionFlowState
@@ -45,6 +48,7 @@ import uniffi.synchronicity.ChangeChannel
import uniffi.synchronicity.DocumentSyncPolicy
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.EnsembleMemberInfo
import uniffi.synchronicity.JobSubjectRef
import uniffi.synchronicity.RemoteDocumentSyncPolicy
import uniffi.synchronicity.SchedulerSettings
import uniffi.synchronicity.remoteConfigUnreachable
@@ -100,7 +104,10 @@ fun SyncSettingsScreen(
)
)
}
var runNowPending by remember { mutableStateOf(false) }
// Only the milliseconds between the tap and the `BackgroundSync` job row existing; after that
// the job is the whole truth, including for a pass the periodic tick or a peer's `TriggerSync`
// started. See `jobs/JobBinding.kt`.
var runNowStarting by remember { mutableStateOf(false) }
var runNowStatus by remember { mutableStateOf<String?>(null) }
var otherNodes by remember { mutableStateOf<List<EnsembleMemberInfo>>(emptyList()) }
@@ -191,7 +198,9 @@ fun SyncSettingsScreen(
fun confirmReclaim(allowUnverified: Boolean) {
val docType = reclaimFlow?.docType ?: return
reclaimFlow = ReclaimFlow(docType, EvictionFlowState.Pending)
// Nothing here says "running" — the `Eviction` job does, and `SyncPolicyControl` reads it.
// This only takes the dialog down.
reclaimFlow = ReclaimFlow(docType, EvictionFlowState.Idle)
scope.launch {
val result =
withContext(Dispatchers.IO) {
@@ -222,11 +231,13 @@ fun SyncSettingsScreen(
}
fun runSchedulerNow() {
runNowPending = true
runNowStarting = true
// The previous run's summary, cleared now that a new one is starting. Nothing here says
// "running" — `runSchedulerPasses` opens the job, and the button reads that.
runNowStatus = null
scope.launch {
val result = withContext(Dispatchers.IO) { nativeLib.settings.runSchedulerPasses() }
runNowPending = false
runNowStarting = false
runNowStatus =
result.fold(
onSuccess = { summary ->
@@ -283,7 +294,7 @@ fun SyncSettingsScreen(
// same as desktop hides it while editing a remote node.
BackgroundSyncSection(
settings = schedulerSettings,
runNowPending = runNowPending,
runNowStarting = runNowStarting,
runNowStatus = runNowStatus,
onSettingsChange = { updateSchedulerSettings(it) },
onRunNow = { runSchedulerNow() },
@@ -405,11 +416,17 @@ private val INTERVAL_PRESETS =
@Composable
private fun BackgroundSyncSection(
settings: SchedulerSettings,
runNowPending: Boolean,
runNowStarting: Boolean,
runNowStatus: String?,
onSettingsChange: (SchedulerSettings) -> Unit,
onRunNow: () -> Unit,
) {
// The job decides, not this screen: a pass may already be under way because the periodic tick
// fired or a peer sent `TriggerSync`. `JobSubjectRef.None` because a full pass is about the
// node, not one peer or document — which is also what makes those three dedup against each
// other.
val control =
rememberJobControl(JobKinds.BACKGROUND_SYNC, JobSubjectRef.None, starting = runNowStarting)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionHeading("Background sync")
Text(
@@ -473,15 +490,17 @@ private fun BackgroundSyncSection(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onRunNow, enabled = !runNowPending) {
Text(if (runNowPending) "Running…" else "Run now")
}
runNowStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onRunNow, enabled = !control.busy) { Text("Run now") }
JobActivity(control = control, fallback = "Running…")
// Hidden while a pass runs, so a finished run's numbers can't be read as this one's.
if (!control.busy) {
runNowStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -13,8 +13,15 @@ sealed class EvictionFlowState {
/** Evidence gathered; showing [EvictionConfirmDialog] for the user to confirm or cancel. */
data class Confirming(val plan: EvictionPlanItem) : EvictionFlowState()
/** Deleting/reclaiming what the confirmed plan said was safe. */
object Pending : EvictionFlowState()
/**
* Nothing of the screen's own to say: the eviction is under way and its `Eviction` job is what
* reports it, or the flow was opened and dismissed.
*
* There is deliberately no `Pending` here. Whether the work is running is a fact about the job
* — which a screen this one never started can also be showing — and a boolean beside it would
* be a second, private answer to the same question. See `jobs/JobBinding.kt`.
*/
object Idle : EvictionFlowState()
data class Done(val message: String) : EvictionFlowState()
+24 -9
View File
@@ -347,15 +347,25 @@ now", the Ensemble screen's per-peer "Sync", and import triggers. The
existing per-widget `AsyncActionState` pattern gets replaced by "subscribe to
job id."
**Status**: built on Android and on the gpui desktop, over the same five
controls — the document detail screen's "Sync to this node" (desktop:
"Download"), its per-artifact "Sync", and its per-peer "Push now"; the
Ensemble screen's per-peer "Sync"; and the Documents screen's imports. Two
consequences beyond the progress readout, and they are the point: a job
started *anywhere else* — the background scheduler, a remote `TriggerSync`,
another screen, this screen before it was navigated away from — shows up in
these controls, and the state survives leaving and re-entering the screen,
because it was never the screen's to remember.
**Status**: built on Android and on the gpui desktop, over the same set of
controls — every one that starts a job. The document detail screen's "Sync to
this node" (desktop: "Download"), its per-artifact "Sync", its per-peer "Push
now", and its "Delete Locally"; the Ensemble screen's per-peer "Sync"; the
Sync policy screen's "Run now" and each type's "Reclaim…"; the Music screen's
"Build library"; and the Documents screen's imports. Two consequences beyond
the progress readout, and they are the point: a job started *anywhere else* —
the background scheduler, a remote `TriggerSync`, another screen, this screen
before it was navigated away from — shows up in these controls, and the state
survives leaving and re-entering the screen, because it was never the screen's
to remember.
The two evictions are worth naming separately because they share a `JobKind`
and must not share an identity: "Delete Locally" is `Eviction`/`Document` and
"Reclaim…" is `Eviction`/`DocumentType`. A subtree eviction and a type-scoped
reclaim are different work, so they neither dedup against each other nor mask
each other's control. Both keep an `Evaluating` state of their own, which is
not a job: gathering the evidence for the confirmation dialog is work no other
node can see.
The mechanism is per-modality, the identity is not. A widget names the work
it starts as a `(kind, subject)` pair, and the pair is spelled in Rust
@@ -376,6 +386,11 @@ either frontend.
one that starts it inside a spawned task (the Ensemble screen's "Sync")
passes `starting: true` for the gap.
`JobSubjectRef` is the FFI spelling of the subject, and it carries
`DocumentType` as the enum rather than its snake_case name for the reason
`job_match_key` exists at all: a frontend that had to produce that name would
be a mirror of `DocumentType::type_name`, and mirrors go stale silently.
What stays a per-screen flag on both is the *result* of the last action —
"Pushed 3 documents", "Sync failed: …". A job carries a state and a progress
message, not the sentence a particular screen wants to show afterwards. It is
+24 -10
View File
@@ -416,9 +416,8 @@ pub fn job_kind_label(kind: String) -> String {
/// A job's subject, in the shapes a frontend already has in hand — the FFI
/// spelling of [`JobSubject`], and the argument to [`job_match_key`].
///
/// Only the variants a widget can bind to are here. `JobSubject::None` is
/// deliberately absent: it is the subject of whole-node work
/// (`BackgroundSync`), which no per-thing widget speaks for.
/// Only the variants a widget can bind to are here — `JobSubject::Path` is a
/// folder import's tree URI, and nothing else in [`JobSubject`] is missing.
#[derive(uniffi::Enum)]
pub enum JobSubjectRef {
/// One peer node, by its hex node id — a `CatalogueSync`.
@@ -430,10 +429,21 @@ pub enum JobSubjectRef {
DocumentToPeer { document_id: String, node_id_hex: String },
/// One artifact of one document, pulled on its own.
DocumentAsset { document_id: String, asset: DocumentAssetRef },
/// Every document of one type, by its snake_case type name.
DocumentType { type_name: String },
/// Every document of one type — a type-scoped `Eviction` (the Sync policy
/// tab's "Reclaim…") or a `LibraryBuild`.
///
/// The enum itself rather than its snake_case name: the name is Rust's
/// spelling, and a frontend that had to produce one would be a mirror of
/// `DocumentType::type_name` — exactly what `job_match_key` exists to
/// avoid. Kotlin already holds `DocumentType` values.
DocumentType { doc_type: DocumentType },
/// A filesystem path or content URI — a folder import's tree URI.
Path { path: String },
/// Work about the node as a whole rather than any one thing in it: a
/// `BackgroundSync` pass. Its widget is the Settings screen's "Run now",
/// which speaks for whichever pass is running — the periodic tick's, a
/// peer's `TriggerSync`, or its own.
None,
}
impl TryFrom<JobSubjectRef> for JobSubject {
@@ -457,12 +467,9 @@ impl TryFrom<JobSubjectRef> for JobSubject {
JobSubjectRef::DocumentAsset { document_id, asset } => {
JobSubject::DocumentAsset(document(document_id)?, asset.try_into()?)
}
JobSubjectRef::DocumentType { type_name } => {
JobSubject::DocumentType(DocumentType::from_str(&type_name).map_err(|e| {
SyncError::InvalidInput { msg: format!("invalid document type: {e}") }
})?)
}
JobSubjectRef::DocumentType { doc_type } => JobSubject::DocumentType(doc_type),
JobSubjectRef::Path { path } => JobSubject::Path(path),
JobSubjectRef::None => JobSubject::None,
})
}
}
@@ -1304,6 +1311,13 @@ mod tests {
JobKind::Import,
JobSubject::Path("content://tree/Music".to_string()),
)]
#[case(
"eviction",
JobSubjectRef::DocumentType { doc_type: DocumentType::MusicTrack },
JobKind::Eviction,
JobSubject::DocumentType(DocumentType::MusicTrack),
)]
#[case("background_sync", JobSubjectRef::None, JobKind::BackgroundSync, JobSubject::None)]
fn a_widgets_key_is_the_key_on_its_job_row(
#[case] kind_name: &str,
#[case] widget_subject: JobSubjectRef,
+3
View File
@@ -1012,6 +1012,9 @@ mod tests {
members,
total_documents: 0,
this_node_is_revoked: false,
current_epoch: 0,
this_node_epoch: 0,
this_node_epoch_is_stale: false,
},
created_at_display: String::new(),
});