@@ -12,6 +12,8 @@ import kotlinx.coroutines.SupervisorJob
1212import kotlinx.coroutines.flow.launchIn
1313import kotlinx.coroutines.flow.onEach
1414import kotlinx.coroutines.launch
15+ import kotlinx.coroutines.sync.Mutex
16+ import kotlinx.coroutines.sync.withLock
1517import kotlinx.serialization.json.JsonObject
1618
1719/* *
@@ -42,67 +44,78 @@ class PayCraftRealtime(private val supabase: SupabaseClient) {
4244
4345 private val scope = CoroutineScope (SupervisorJob () + Dispatchers .Default )
4446
47+ // All channel state is read/written ONLY inside [mutex] — this makes the
48+ // check-then-subscribe atomic (no duplicate channel when startRealtime is
49+ // called twice per config fetch) and the fields thread-safe across the
50+ // Default-dispatcher coroutines + the calling thread (JVM + Kotlin/Native).
51+ private val mutex = Mutex ()
4552 private var configChannel: RealtimeChannel ? = null
4653 private var entitlementChannel: RealtimeChannel ? = null
4754 private var configTenant: String? = null
4855 private var entitlementKey: String? = null // "tenant:appUserId"
4956
5057 /* * Subscribe to `config:{tenantId}`; invoke [onChanged] on every config ping. */
5158 fun ensureConfigChannel (tenantId : String , onChanged : () -> Unit ) {
52- if (configTenant == tenantId && configChannel != null ) return
5359 scope.launch {
54- runCatching {
55- configChannel?.let { supabase.realtime.removeChannel(it) }
56- val ch = supabase.channel(" config:$tenantId " )
57- ch.broadcastFlow<JsonObject >(event = " config_changed" )
58- .onEach {
59- PayCraftLogger .onFlow(" realtime" , " config ping → refetching /config" )
60- onChanged()
61- }
62- .launchIn(scope)
63- ch.subscribe()
64- configChannel = ch
65- configTenant = tenantId
66- PayCraftLogger .onFlow(" realtime" , " subscribed config:$tenantId " )
67- }.onFailure {
68- PayCraftLogger .onFlow(" realtime" , " config subscribe failed (TTL fallback stays): ${it.message} " )
60+ mutex.withLock {
61+ if (configTenant == tenantId && configChannel != null ) return @withLock
62+ runCatching {
63+ configChannel?.let { supabase.realtime.removeChannel(it) }
64+ val ch = supabase.channel(" config:$tenantId " )
65+ ch.broadcastFlow<JsonObject >(event = " config_changed" )
66+ .onEach {
67+ PayCraftLogger .onFlow(" realtime" , " config ping → refetching /config" )
68+ onChanged()
69+ }
70+ .launchIn(scope)
71+ ch.subscribe()
72+ configChannel = ch
73+ configTenant = tenantId
74+ PayCraftLogger .onFlow(" realtime" , " subscribed config:$tenantId " )
75+ }.onFailure {
76+ PayCraftLogger .onFlow(" realtime" , " config subscribe failed (TTL fallback stays): ${it.message} " )
77+ }
6978 }
7079 }
7180 }
7281
7382 /* * Subscribe to `entitlement:{tenantId}:{appUserId}`; re-subscribes on identity change. */
7483 fun ensureEntitlementChannel (tenantId : String , appUserId : String , onChanged : () -> Unit ) {
7584 val key = " $tenantId :$appUserId "
76- if (entitlementKey == key && entitlementChannel != null ) return
7785 scope.launch {
78- runCatching {
79- entitlementChannel?.let { supabase.realtime.removeChannel(it) }
80- val ch = supabase.channel(" entitlement:$tenantId :$appUserId " )
81- ch.broadcastFlow<JsonObject >(event = " entitlement_changed" )
82- .onEach {
83- PayCraftLogger .onFlow(" realtime" , " entitlement ping → force refresh" )
84- onChanged()
85- }
86- .launchIn(scope)
87- ch.subscribe()
88- entitlementChannel = ch
89- entitlementKey = key
90- PayCraftLogger .onFlow(" realtime" , " subscribed entitlement:$tenantId :***" )
91- }.onFailure {
92- PayCraftLogger .onFlow(" realtime" , " entitlement subscribe failed (TTL fallback stays): ${it.message} " )
86+ mutex.withLock {
87+ if (entitlementKey == key && entitlementChannel != null ) return @withLock
88+ runCatching {
89+ entitlementChannel?.let { supabase.realtime.removeChannel(it) }
90+ val ch = supabase.channel(" entitlement:$tenantId :$appUserId " )
91+ ch.broadcastFlow<JsonObject >(event = " entitlement_changed" )
92+ .onEach {
93+ PayCraftLogger .onFlow(" realtime" , " entitlement ping → force refresh" )
94+ onChanged()
95+ }
96+ .launchIn(scope)
97+ ch.subscribe()
98+ entitlementChannel = ch
99+ entitlementKey = key
100+ PayCraftLogger .onFlow(" realtime" , " subscribed entitlement:$tenantId :***" )
101+ }.onFailure {
102+ PayCraftLogger .onFlow(" realtime" , " entitlement subscribe failed (TTL fallback stays): ${it.message} " )
103+ }
93104 }
94105 }
95106 }
96107
97108 /* * Tear down both channels (call on logout / SDK teardown). */
98109 fun stop () {
99110 scope.launch {
100- configChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
101- entitlementChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
102- configChannel = null
103- entitlementChannel = null
104- configTenant = null
105- entitlementKey = null
111+ mutex.withLock {
112+ configChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
113+ entitlementChannel?.let { runCatching { supabase.realtime.removeChannel(it) } }
114+ configChannel = null
115+ entitlementChannel = null
116+ configTenant = null
117+ entitlementKey = null
118+ }
106119 }
107120 }
108121}
0 commit comments