Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions docs/adr/0015-snapshot-drive-lease-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,27 @@ existente); um manifest de sequência monotônica por aparelho decide quem pode
de escolher, nunca só o lado que a tela presume perdedor. O `sync_log` hoje só registra
import/write-back da planilha; a lista reflete exatamente o que está gravado ali, sem
instrumentar gestos de domínio (splits, tags, teto, faturas) — expandir essa cobertura é decisão
própria, fora do escopo deste mecanismo.
própria, fora do escopo deste mecanismo. O corte "desde a base em comum" é por SEQUÊNCIA
(`sync_log.seq`, um contador monotônico gravado por trigger em cada linha — nunca o rowid
implícito do SQLite, que `VACUUM INTO` pode renumerar), não por timestamp: os dois aparelhos
eram bytes idênticos no momento do último sync, então `MAX(seq)` daquele instante
(`snapshot_state.base_sync_log_seq`) tem o MESMO significado nos dois lados sem depender de qual
relógio está certo — um relógio remoto atrasado nunca esconde um gesto de fato posterior à base.
O `sync_log` TEM deleções (diff-delete do re-import, delete manual de transação), então o
contador não pode ler `MAX(seq)` das linhas remanescentes a cada atribuição — um delete
encolhendo o log faria o próximo insert reusar um número já emitido, abaixo da âncora de algum
aparelho. `sync_log.seq` é gerado por uma tabela auxiliar de uma linha só
(`sync_log_seq`, `INTEGER PRIMARY KEY AUTOINCREMENT`) cujo watermark real mora em
`sqlite_sequence` — a garantia nativa do SQLite de nunca reemitir um valor, mesmo depois de
`DELETE` na tabela — e que sobrevive ao `VACUUM INTO`/download junto com o resto do snapshot.
- O transporte fica atrás do `appDataFolder` do Drive — invisível ao dono, escopo OAuth estreito
(`drive.appdata`, não acesso aos arquivos do Google Drive do usuário).
- **Manifest com o próprio `device_id` só é "eu mesmo" dentro de uma janela estreita**: o
check-out trata um manifest remoto com o `device_id` deste aparelho como o check-in que morreu
entre o upload confirmado e a gravação do estado local — e só avança a base sem baixar nem
trocar arquivo — quando `remote.sequence == base_local + 1`, a largura exata dessa queda.
trocar arquivo — quando `remote.sequence` bate com `snapshot_state.pending_publish_sequence`, a
sequência PRETENDIDA gravada antes do upload (nunca a aritmética `base_local + 1`, que só cobria
o check-in normal — a resolução de conflito mantendo este aparelho publica além disso).
Qualquer outra sequência com o mesmo id passa pelo veredito normal do árbitro (restauração
visível). A identidade pode colidir por um caminho fora do lease (cópia manual da pasta do app,
backup local restaurado à mão sem passar pelo strip do export) — nesse caso as duas instalações
Expand Down
3 changes: 2 additions & 1 deletion specs/043-snapshot-drive/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ O perímetro de confiança não muda: o dado já vive no Google pela planilha; o
- Transporte pelo cliente HTTP e pelo refresh de token existentes; o escopo `drive.appdata` é adicionado aos escopos do OAuth, o que força re-consentimento único — documentado na UI de Conexão como mudança esperada.
- Gatilhos: check-out (restauração de verdade) só ao abrir; ao ganhar foco roda uma sonda mais leve (mesmo debounce de probe do sync da planilha) que apenas AVISA quando o remoto avançou, sem baixar nem trocar o banco ativo — o pool já está `app.manage()`-do e em uso, então a convergência de verdade só acontece no próximo boot. Check-in ao fechar e após gesto material com debounce — a definição de "gesto material" é a mesma que o `sync_log` já registra.
- O import da planilha também avança a sequência local: importar É mudar o banco; o lease trata igual.
- **Check-out com manifest do próprio `device_id`**: o veredito `Pull` do árbitro para um manifest que carrega o `device_id` deste aparelho só é tratado como "check-in próprio que morreu entre o upload confirmado e a gravação do estado local" quando `remote.sequence == base_local + 1` — a janela exata daquela queda. Só nesse caso a base local avança para alcançar o remoto sem baixar nem trocar arquivo. Qualquer outra sequência com o mesmo `device_id` (duas instalações compartilhando identidade por um caminho lateral — cópia manual da pasta do app, backup local restaurado à mão sem passar pelo strip do export) segue o veredito normal do árbitro: restauração de verdade, registrada na linha "Última leitura do Drive" e com a salvaguarda local de sempre. A largura ampla foi avaliada e descartada por adotar silenciosamente o conteúdo de uma instalação alheia sempre que a identidade colide.
- **Check-out com manifest do próprio `device_id`**: o veredito `Pull` do árbitro para um manifest que carrega o `device_id` deste aparelho só é tratado como "check-in próprio que morreu entre o upload confirmado e a gravação do estado local" quando `remote.sequence` bate com `snapshot_state.pending_publish_sequence` — a sequência PRETENDIDA gravada antes do upload (check-in normal ou resolução de conflito mantendo este aparelho), nunca a aritmética `base_local + 1` (que só cobria o check-in normal — a resolução de conflito pode publicar além disso). Só nesse caso a base local avança para alcançar o remoto sem baixar nem trocar arquivo. Qualquer outra sequência com o mesmo `device_id` (duas instalações compartilhando identidade por um caminho lateral — cópia manual da pasta do app, backup local restaurado à mão sem passar pelo strip do export) segue o veredito normal do árbitro: restauração de verdade, registrada na linha "Última leitura do Drive" e com a salvaguarda local de sempre. A largura ampla foi avaliada e descartada por adotar silenciosamente o conteúdo de uma instalação alheia sempre que a identidade colide.
- **Corte da lista de gestos do conflito por sequência, não por timestamp**: `sync_log.seq` (contador monotônico gravado por trigger em cada linha, nunca o rowid implícito do SQLite — que `VACUUM INTO` pode renumerar) e `snapshot_state.base_sync_log_seq` (`MAX(seq)` capturado sempre que a base avança) substituem a antiga aproximação por `MAX(last_checkin_at, last_checkout_at)`. Os dois aparelhos eram bytes idênticos no momento do último sync, então a âncora tem o MESMO significado nos dois lados — nunca depende de qual relógio está certo, ao contrário do corte por timestamp (que um relógio remoto atrasado podia esconder gestos de fato posteriores à base).
- Conflito não tenta merge por linha: a resolução é escolher o vencedor, com a lista de gestos do perdedor (extraída do `sync_log` entre a base comum e a ponta perdedora) exibida antes da escolha.
- Tudo entra pelo funil de views (ADR-0006/0008): a UI de estado do sync vive na superfície de Conexão existente; a tela de conflito é estado do shell.
- Esta spec entrega o mecanismo completo hospedado no desktop (onde já é exercitável de ponta a ponta, inclusive simulando o segundo aparelho); o Android consome na spec do porte sem mudança de contrato.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Sequência PRETENDIDA de uma publicação em andamento (ADR-0015, issue #446 item 11): gravada
-- ANTES do upload para o Drive e limpa quando `record_checkin` confirma que a gravação local
-- terminou. Cobre o "check-in morto" (upload confirmado, gravação local que morreu antes de
-- terminar) para QUALQUER publicação — check-in normal (candidato sempre `base + 1`) e resolução
-- de conflito mantendo este aparelho (`resolve_conflict_keep_local_core`, candidato
-- `max(base + 1, remote + 1)`, que pode passar de `base + 1`). A guarda do próprio `device_id` em
-- `checkout::checkout_on_open` compara o manifest remoto contra ESTE valor em vez de inferir a
-- janela por aritmética (`base + 1`), que só cobria a primeira publicação.
ALTER TABLE snapshot_state ADD COLUMN pending_publish_sequence INTEGER;
67 changes: 67 additions & 0 deletions src-tauri/migrations/20260814000002_sync_log_sequence.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
-- Âncora de corte por SEQUÊNCIA para a lista de gestos do conflito (ADR-0015, issue #446 D3 do
-- PR #447): o corte anterior comparava `sync_log.timestamp` do OUTRO aparelho contra uma âncora
-- derivada do relógio DESTE — um relógio remoto atrasado escondia gestos recentes dele, e a lista
-- ficava mais estreita que a verdade. `seq` é um contador monotônico gravado NA LINHA (nunca o
-- rowid implícito do SQLite, que `VACUUM INTO` pode renumerar para tabelas sem `INTEGER PRIMARY
-- KEY` — `sync_log.id` é `TEXT`, então o rowid NÃO é estável através do export) — sobrevive
-- intacto ao `VACUUM INTO`/download porque é um valor de COLUNA, não posição física de
-- armazenamento. Os dois aparelhos eram bytes idênticos no momento do último sync (o snapshot
-- inteiro viaja), então `MAX(seq)` naquele instante tem o MESMO significado nos dois lados —
-- comparar contra ele nunca depende de qual relógio está certo.
--
-- Trigger em vez de tocar os ~10 pontos de INSERT espalhados pelo código: nenhum call site precisa
-- saber que `seq` existe, e nenhum pode esquecer de preenchê-lo. `WHEN NEW.seq IS NULL` deixa um
-- valor explícito (só usado em teste) passar sem ser sobrescrito.
ALTER TABLE sync_log ADD COLUMN seq INTEGER;

-- `sync_log` TEM deleções em produção (diff-delete do re-import, `google_sheets/import/mod.rs`;
-- delete manual de transação, `commands/transactions.rs`) — um gerador que lesse
-- `MAX(seq) FROM sync_log` a cada insert recuaria quando as linhas de maior `seq` fossem
-- apagadas, e o próximo insert reusaria um número já emitido (invisível para quem ancorou a base
-- num `seq` mais alto que já não existe mais). `sync_log_seq` é só um contador: nunca guarda
-- histórico (o trigger abaixo apaga a única linha antes de cada novo insert), e o que garante que
-- o próximo valor NUNCA repete um já emitido é o `AUTOINCREMENT` do SQLite — o dono real da
-- monotonicidade mora em `sqlite_sequence`, uma tabela de sistema que sobrevive a `DELETE` e
-- viaja junto no `VACUUM INTO` (verificado: um `INTEGER PRIMARY KEY AUTOINCREMENT` esvaziado e
-- exportado continua, no arquivo restaurado, do maior valor já emitido na origem — nunca do que
-- resta nas linhas).
CREATE TABLE IF NOT EXISTS sync_log_seq (
n INTEGER PRIMARY KEY AUTOINCREMENT
);

CREATE TRIGGER IF NOT EXISTS sync_log_assign_seq
AFTER INSERT ON sync_log
WHEN NEW.seq IS NULL
BEGIN
DELETE FROM sync_log_seq;
-- `INSERT ... DEFAULT VALUES` não é aceito dentro de corpo de trigger pelo parser do SQLite
-- (só fora de trigger); `VALUES (NULL)` produz o MESMO efeito num `INTEGER PRIMARY KEY
-- AUTOINCREMENT` — o `NULL` explícito ainda aciona a atribuição automática.
INSERT INTO sync_log_seq (n) VALUES (NULL);
UPDATE sync_log
SET seq = (SELECT n FROM sync_log_seq)
WHERE rowid = NEW.rowid;
END;

-- Backfill: linhas que já existiam antes desta migration nunca passaram pelo trigger acima.
-- Ordem cronológica (a mesma que o corte por `timestamp` já usava) é a melhor aproximação
-- disponível para dar a elas um `seq` coerente com a ordem real dos gestos passados.
WITH ordered AS (
SELECT rowid AS rid, ROW_NUMBER() OVER (ORDER BY datetime(timestamp), rowid) AS rn
FROM sync_log
)
UPDATE sync_log
SET seq = (SELECT rn FROM ordered WHERE ordered.rid = sync_log.rowid)
WHERE seq IS NULL;

-- Ancora o gerador no maior `seq` já emitido pelo backfill acima — um INSERT com valor EXPLÍCITO
-- também vira o novo watermark em `sqlite_sequence` (o mesmo mecanismo do trigger), então o
-- PRÓXIMO gesto inserido continua a sequência em vez de recomeçar do 1. `COALESCE(..., 0)` cobre
-- o `sync_log` vazio: o primeiro gesto real, então, começa em 1.
INSERT INTO sync_log_seq (n) SELECT COALESCE(MAX(seq), 0) FROM sync_log;

-- Sequência-base local (ADR-0015): o `MAX(seq)` do `sync_log` no momento em que `base_sequence`
-- avançou por último (check-in, check-out real, ou adoção da própria sequência) — a âncora única
-- que substitui a aproximação por `MAX(last_checkin_at, last_checkout_at)` (`conflict::base_anchor`,
-- removida). `NULL` = nenhuma base ainda ou `sync_log` vazio naquele momento (nada a excluir).
ALTER TABLE snapshot_state ADD COLUMN base_sync_log_seq INTEGER;
Loading