Sincronização via iCloud restrita ao Pro para a lista "Não Interessado"
O tier Pro do Deep Cut Atlas promete que dispensar um álbum em “Não Interessado” te acompanha entre dispositivos e sobrevive a uma reinstalação. Construí isso essa semana, e as partes interessantes foram as que eu não tinha planejado.
A decisão que tornou isso fácil
Um spike de design de algumas semanas atrás já tinha descartado a abordagem óbvia. Trocar o ModelConfiguration(cloudKitDatabase:) do SwiftData para sincronizar tudo via CloudKit parece simples, mas não dá para alternar em tempo de execução, e no iOS 17.0-17.1 ele sincroniza mesmo quando você diz para não sincronizar. Como a restrição ao Pro significa que usuários free nunca podem tocar no iCloud, isso descartou a opção por completo.
A lista de dispensados em si é pequena, aditiva e de baixo risco. Pior caso se a sincronização engasgar: um álbum já visto reaparece. Essa é exatamente a forma que o key-value store do iCloud lida bem, então o plano foi um único blob JSON sob uma chave do KVS, com um union-merge que mantém o SwiftData local como fonte da verdade e só empilha a sincronização por cima.
Reaproveitando mais do que eu esperava
Eu entrei esperando escrever bastante encanamento novo. Acabou que uma funcionalidade anterior (sincronizar quais playlists o app tinha criado) já tinha construído exatamente o encaixe de que eu precisava: um protocolo pequeno envolvendo NSUbiquitousKeyValueStore, mais um dublê de teste em memória. Não toquei em nenhum dos dois. Só construí um novo serviço em cima da mesma abstração.
A única peça que o union-merge não resolve sozinho são as exclusões. Se você desfizer uma dispensa no seu celular, e a cópia do dataset no seu iPad ainda tiver ela, um union simples traz de volta na hora. A correção são tombstones: em vez de remover uma chave do conjunto sincronizado, você a marca como excluída com um timestamp, e a reconciliação compara timestamps para decidir se uma exclusão ou uma readição vence.
Para evitar que cada ponto de chamada precisasse saber sobre status Pro, sincronização e iCloud, envolvi o armazenamento local existente num decorator que implementa exatamente a mesma interface. Usuários free recebem o armazenamento simples, intacto. Usuários Pro recebem o mesmo armazenamento com uma camada fina de sincronização por cima. Uma função decide qual, em um único lugar.
O crash que derrubou os testes errados
Escrevendo testes para a lógica de reconciliação, esbarrei numa parede: a rodada de testes inteira começou a falhar. Não só meus testes novos, dezenas de outros sem relação alguma por todo o código, todos relatando uma duração uniforme suspeita de 0,000 segundos. Minha primeira leitura foi que eu tinha quebrado algo sistêmico. Não tinha.
O que de fato aconteceu: um dos meus testes derrubou o processo de teste compartilhado por completo, e tudo que ainda estava na fila atrás dele nesse processo foi marcado como “falhou” sem nunca chegar a rodar. O crash real estava enterrado sob uma pilha de dano colateral.
O bug em si foi quase constrangedor quando eu finalmente achei. Um helper de teste construía um container do SwiftData, e retornava só o context dele, não o container em si. Nada ficou segurando o container vivo depois que a função retornava, então no momento em que um teste posterior tocava aquele context, ele estava operando sobre um armazenamento já desmontado. Signal trap, sem mensagem útil.
A parte irritante é que eu já tinha sido avisado. Um arquivo de teste anterior nesse mesmo código tem um comentário sobre esse exato modo de falha. Só não conectei os pontos ao escrever um helper novo para um arquivo de teste novo. Lição arquivada direito dessa vez, numa nota da base de conhecimento em vez de um comentário que eu poderia não ver da próxima vez.
Provando sem um segundo dispositivo
O teste real para “isso sincroniza via iCloud” são dois dispositivos na mesma conta. Eu só tinha um à mão. Testar localmente normalmente não provaria nada, já que uma dispensa persiste localmente de qualquer jeito, independente de a sincronização funcionar ou não.
O truque foi quebrar essa suposição de propósito. Dispensei um álbum com o Pro ativo, dei um tempinho para o iCloud fazer o push, e então apaguei o app inteiro, levando o armazenamento local junto. Reinstalei, reabri, conferi se aquele álbum já estava excluído do feed sem eu tocar nele de novo. Estava. Como o armazenamento local não tinha nada a dizer sobre ele, a única forma daquele álbum continuar marcado como dispensado era ele ter voltado do iCloud na abertura. Isso é o mais perto de prova que dá para chegar com um único dispositivo.
Onde isso está
Os caminhos de push e pull funcionam os dois, confirmados do jeito difícil em vez de presumidos. O que ainda está sem verificação é a propagação em tempo real entre dois dispositivos ativos, já que eu não tinha um segundo à mão nessa rodada. Isso é uma checagem de cinco minutos assim que um iPad ou celular sobressalente estiver disponível, não uma lacuna de design.
Leitura relacionada
Uma seção de tracklist, e por que levou 30 minutos
Um método de protocolo, um enum de estado reaproveitado, uma convenção de roteamento que se manteve, e um orçamento de lint que forçou uma divisão que valia a pena fazer de qualquer forma.
A playlist que já tinha o nome certo
Um artefato de renomeação, uma API sem campo de autor pra atualizar, e um teste em dispositivo real provando que o bug já tinha se corrigido sozinho, fechado como aceitar como está.
Álbuns em destaque, dados reais do MusicKit, e um teste que mentiu sobre estar passando
Não existe uma flag isEssential - o Essentials que você vê é uma playlist de músicas. featuredAlbums é o sinal real, e uma mudança de fixture expôs um teste que ninguém tinha rodado de novo.
Você também pode achar útil
NordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba maisProton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba mais