11package com.powersync.db.internal
22
3+ import co.touchlab.kermit.Logger
34import com.powersync.ExperimentalPowerSyncAPI
4- import com.powersync.PowerSyncException
55import com.powersync.db.SqlCursor
66import com.powersync.db.ThrowableLockCallback
77import com.powersync.db.ThrowableTransactionCallback
@@ -15,8 +15,9 @@ import kotlinx.coroutines.Dispatchers
1515import kotlinx.coroutines.IO
1616import kotlinx.coroutines.flow.Flow
1717import kotlinx.coroutines.flow.SharedFlow
18- import kotlinx.coroutines.flow.channelFlow
19- import kotlinx.coroutines.flow.filter
18+ import kotlinx.coroutines.flow.emitAll
19+ import kotlinx.coroutines.flow.flow
20+ import kotlinx.coroutines.flow.map
2021import kotlinx.coroutines.flow.onSubscription
2122import kotlinx.coroutines.flow.transform
2223import kotlinx.coroutines.withContext
@@ -25,6 +26,7 @@ import kotlin.time.Duration.Companion.milliseconds
2526@OptIn(ExperimentalPowerSyncAPI ::class )
2627internal class InternalDatabaseImpl (
2728 private val pool : SQLiteConnectionPool ,
29+ private val logger : Logger ,
2830) : InternalDatabase {
2931 // Could be scope.coroutineContext, but the default is GlobalScope, which seems like a bad idea. To discuss.
3032 private val dbContext = Dispatchers .IO
@@ -79,74 +81,74 @@ internal class InternalDatabaseImpl(
7981 tables : Set <String >,
8082 throttleMs : Long ,
8183 triggerImmediately : Boolean ,
82- ): Flow <Set <String >> =
83- channelFlow {
84- // Match all possible internal table combinations
85- val watchedTables =
86- tables.flatMap { listOf (it, " ps_data__$it " , " ps_data_local__$it " ) }.toSet()
87-
88- // Accumulate updates between throttles
89- val batchedUpdates = AtomicMutableSet <String >()
84+ ): Flow <Set <String >> {
85+ // Match all possible internal table combinations
86+ val watchedTables =
87+ tables.flatMap { listOf (it, " ps_data__$it " , " ps_data_local__$it " ) }.toSet()
9088
91- updatesOnTables()
92- .onSubscription {
93- if (triggerImmediately) {
94- // Emit an initial event (if requested). No changes would be detected at this point
95- send(setOf ())
96- }
97- }.transform { updates ->
98- val intersection = updates.intersect(watchedTables)
99- if (intersection.isNotEmpty()) {
100- // Transform table names using friendlyTableName
101- val friendlyTableNames = intersection.map { friendlyTableName(it) }.toSet()
102- batchedUpdates.addAll(friendlyTableNames)
103- emit(Unit )
104- }
105- }
106- // Throttling here is a feature which prevents watch queries from spamming updates.
107- // Throttling by design discards and delays events within the throttle window. Discarded events
108- // still trigger a trailing edge update.
109- // Backpressure is avoided on the throttling and consumer level by buffering the last upstream value.
110- .throttle(throttleMs.milliseconds)
111- .collect {
112- // Emit the transformed tables which have changed
113- val copy = batchedUpdates.toSetAndClear()
114- send(copy)
115- }
89+ return rawChangedTables(watchedTables, throttleMs, triggerImmediately).map {
90+ it.mapTo(mutableSetOf (), ::friendlyTableName)
11691 }
92+ }
11793
11894 override fun <RowType : Any > watch (
11995 sql : String ,
12096 parameters : List <Any ?>? ,
12197 throttleMs : Long ,
12298 mapper : (SqlCursor ) -> RowType ,
12399 ): Flow <List <RowType >> =
124- // Use a channel flow here since we throttle (buffer used under the hood)
125- // This causes some emissions to be from different scopes.
126- channelFlow {
100+ flow {
127101 // Fetch the tables asynchronously with getAll
128102 val tables =
129103 getSourceTables(sql, parameters)
130104 .filter { it.isNotBlank() }
131105 .toSet()
132106
107+ val queries =
108+ rawChangedTables(tables, throttleMs, triggerImmediately = true ).map {
109+ logger.v { " Fetching watch() query: $sql " }
110+ val rows = getAll(sql, parameters = parameters, mapper = mapper)
111+ logger.v { " watch query $sql done, emitting downstream" }
112+ rows
113+ }
114+ emitAll(queries)
115+ }
116+
117+ private fun rawChangedTables (
118+ tableNames : Set <String >,
119+ throttleMs : Long ,
120+ triggerImmediately : Boolean ,
121+ ): Flow <Set <String >> =
122+ flow {
123+ val batchedUpdates = AtomicMutableSet <String >()
124+
133125 updatesOnTables()
134- // onSubscription here is very important.
135- // This ensures that the initial result and all updates are emitted.
136126 .onSubscription {
137- send(getAll(sql, parameters = parameters, mapper = mapper))
138- }.filter {
139- // Only trigger updates on relevant tables
140- it.intersect(tables).isNotEmpty()
127+ if (triggerImmediately) {
128+ // Emit an initial event (if requested). No changes would be detected at this point
129+ emit(initialUpdateSentinel)
130+ }
131+ }.transform { updates ->
132+ if (updates == = initialUpdateSentinel) {
133+ // This should always be emitted despite being empty and not intersecting with
134+ // the tables we care about.
135+ emit(Unit )
136+ } else {
137+ val intersection = updates.intersect(tableNames)
138+ if (intersection.isNotEmpty()) {
139+ batchedUpdates.addAll(intersection)
140+ emit(Unit )
141+ }
142+ }
141143 }
142144 // Throttling here is a feature which prevents watch queries from spamming updates.
143145 // Throttling by design discards and delays events within the throttle window. Discarded events
144146 // still trigger a trailing edge update.
145147 // Backpressure is avoided on the throttling and consumer level by buffering the last upstream value.
146- // Note that the buffered upstream "value" only serves to trigger the getAll query. We don't buffer watch results.
147148 .throttle(throttleMs.milliseconds)
148149 .collect {
149- send(getAll(sql, parameters = parameters, mapper = mapper))
150+ val entries = batchedUpdates.toSetAndClear()
151+ emit(entries)
150152 }
151153 }
152154
@@ -259,6 +261,10 @@ internal class InternalDatabaseImpl(
259261 val p2 : Long ,
260262 val p3 : Long ,
261263 )
264+
265+ private companion object {
266+ val initialUpdateSentinel = emptySet<String >()
267+ }
262268}
263269
264270/* *
0 commit comments