Skip to content

fix: fecha follow-ups pendentes do snapshot no Drive (issue #446) - #459

Merged
johnlaff merged 2 commits into
mainfrom
impl/issue-446
Aug 14, 2026
Merged

fix: fecha follow-ups pendentes do snapshot no Drive (issue #446)#459
johnlaff merged 2 commits into
mainfrom
impl/issue-446

Conversation

@johnlaff

Copy link
Copy Markdown
Owner

O quê e por quê

A issue #446 virou um container acumulador de follow-ups de revisão adversarial dos PRs anteriores do mecanismo de sync por snapshot no Drive (#443, #447, #448, #449, #452). O PR #452 já entregou os quatro itens originais do corpo da issue; este PR fecha os onze itens que sobraram no comentário mais recente, sem repetir nada já mergeado.

O item de maior risco real (11a): a guarda que reconhece "esse manifest remoto é a minha própria publicação que morreu entre o upload confirmado e a gravação local" comparava contra a aritmética base_local + 1 — uma janela que só cobria o check-in normal. resolve_conflict_keep_local_core (a resolução de conflito mantendo este aparelho) publica max(base+1, remote+1), que passa de base+1 sempre que a disputa já tinha avançado mais de um passo. Um check-in morto nessa porta específica caía na restauração normal (baixava e trocava o banco ativo pelo próprio snapshot antigo, descartando trabalho local feito depois do upload) em vez de ser reconhecido como "sou eu mesmo".

Mudanças

  • Sequência pretendida em vez de aritmética (pending_publish_sequence, novo campo em snapshot_state): gravada ANTES de qualquer upload (check-in normal e resolução de conflito), limpa quando a gravação local confirma. A guarda do próprio device_id em checkout_on_open e na sonda de foco compara contra este valor, fechando as duas portas.
  • Corte da lista de gestos do conflito por sequência, não timestamp (sync_log.seq, coluna monotônica gravada por trigger SQL — nunca o rowid implícito, que VACUUM INTO pode renumerar; snapshot_state.base_sync_log_seq como âncora única). O corte antigo comparava o timestamp do OUTRO aparelho contra uma âncora derivada do relógio DESTE — um relógio remoto atrasado podia esconder gestos de fato posteriores à base. Os dois aparelhos eram bytes idênticos no momento do último sync, então a nova âncora tem o MESMO significado nos dois lados, sem depender de qual relógio está certo.
  • rollback_to_safeguard copia para temporário e troca por rename em vez de sobrescrever o banco ativo direto — a mesma garantia que swap_active_db_atomically já usa, evitando um banco truncado se a cópia falhar no meio (disco cheio, processo morto).
  • Auto-cura de conflict_pending_since: quando drive_conflict_details_core descobre que a disputa não se sustenta mais (remoto sumiu do Drive, ou não avançou além da base), limpa o flag antes de devolver o erro — sem isso, os gatilhos automáticos ficavam desligados para sempre e a tela de conflito reabria a cada lançamento só para falhar no mesmo fetch.
  • Ajustes menores: role="status" no aviso de check-out (era role="alert", interrompia o leitor de tela a cada visita para um estado passivo recuperável); comentário de driveCheckoutLabel corrigido para atribuir a invariante ao mecanismo certo (a guarda de checkout_on_open, não o árbitro puro, que ignora device_id); guard de unmount consistente nas duas chamadas de loadDetails em SnapshotConflictScreen; conflictRemoteDeviceLabel comparando com this_device_id em vez de cravar "outro aparelho" sempre; App.tsx ganhou cobertura de teste para a costura "tela de conflito abre sozinha" (os dois useEffect que não tinham nenhum teste); a janela esconde imediatamente ao fechar (window.hide()) em vez de ficar parada e visível em silêncio por até 5s enquanto o check-in final tenta a rede.

Investigado e descartado: um item da baseline visual da tela de conflito (snapshot-conflict.aria.yml) usa regex para a recência de um gesto específico, apesar do relógio pinado no teste. Reproduzi a regeneração de forma independente com um fixture diferente e confirmei que o valor é perfeitamente estável (10 leituras reais, mesmo resultado) — a regex vem do próprio gerador de aria snapshot do Playwright, não de uma condição de corrida real; o mesmo padrão aparece em dezenas de outras baselines deste repo para valores que nem variam com o tempo (ex.: valores monetários). Não é um relaxamento de asserção sem causa a corrigir.

Como verificar

  • npm run check — gate completo (format, lint, typecheck, testes, build, cargo test/clippy/rustfmt, privacy scan, comment hygiene, UI audit) — 100% verde, 1397 testes Rust + suíte TS.
  • npx playwright test tests/e2e/snapshot-conflict-visual.spec.ts — baseline visual regenerada (regra 38 do ui-standards: apagar, gravar, verificar 2×) e 100% verde.
  • TDD seguido: o árbitro de lease já era puro/testado; cada mudança de comportamento (guarda por sequência pretendida, corte por seq, rollback copiar-e-renomear, auto-cura do conflito) ganhou teste de regressão ANTES/junto da implementação, incluindo o cenário exato "check-in morto além de base+1" reproduzido tanto no boot quanto na sonda de foco.

Closes #446

… fecha as duas portas da guarda de device_id, corte de gestos por sequência em vez de relógio, e reversão do rollback por copiar-e-renomear

Onze itens acumulados na issue #446 (follow-ups de revisão adversarial dos PRs #443/#447/#448/#449/#452), sem repetir os quatro já entregues no #452:

- A guarda do próprio device_id em checkout_on_open comparava contra a aritmética `base + 1`, que só cobria o check-in normal. resolve_conflict_keep_local_core publica além disso (`max(base+1, remote+1)`), então um check-in morto nessa porta caía na restauração normal em vez de ser reconhecido — corrigido gravando a sequência PRETENDIDA (`pending_publish_sequence`) antes de qualquer upload e comparando a guarda contra ela, nos dois pontos de entrada (boot e sonda de foco).
- O corte da lista de gestos do conflito usava timestamp contra o relógio do OUTRO aparelho, reintroduzindo a dependência que o árbitro do lease existe para evitar. Trocado por uma sequência monotônica gravada por trigger em cada linha do sync_log (sobrevive ao VACUUM INTO do export, ao contrário do rowid implícito) e uma âncora única (`base_sync_log_seq`) capturada sempre que a base local avança.
- rollback_to_safeguard sobrescrevia o banco ativo direto; agora copia para um temporário e troca por rename, a mesma garantia que swap_active_db_atomically já usa.
- drive_conflict_details_core limpa conflict_pending_since quando descobre que a disputa não se sustenta mais (remoto sumiu, ou não avançou), liberando os gatilhos automáticos sem exigir um reinício.
- Ajustes menores: role="status" no aviso de check-out (era role="alert"), comentário de driveCheckoutLabel corrigido para atribuir a invariante ao mecanismo certo, guard de unmount consistente em SnapshotConflictScreen, conflictRemoteDeviceLabel comparando com this_device_id, App.tsx ganhou cobertura de teste para a costura de conflito automático, e a janela esconde imediatamente ao fechar em vez de ficar parada em silêncio por até 5s.

Investigado e descartado: o regex de recência num item da baseline visual do conflito é gerado pelo próprio Playwright (reproduzido de forma independente, mesmo padrão presente em dezenas de outras baselines do repo) — não é um relaxamento de asserção sem causa.

Closes #446
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit cc872ad.

…conder gestos na tela de conflito

O trigger que atribuía seq lia MAX(seq) diretamente do sync_log a cada insert. Como a tabela tem
deleções em produção (diff-delete do re-import, delete manual de transação), apagar as linhas de
maior seq derrubava o MAX corrente — o próximo gesto inserido reusava um número já emitido, abaixo
da âncora de algum aparelho, e a lista de conflito voltava a omitir gestos reais.

A monotonicidade agora mora numa tabela geradora de uma linha só (sync_log_seq, INTEGER PRIMARY
KEY AUTOINCREMENT): o watermark real fica em sqlite_sequence, que o SQLite garante nunca reemitir
mesmo após DELETE na tabela, e que sobrevive ao VACUUM INTO junto com o resto do snapshot
(verificado por teste de round-trip export→restore).
@johnlaff

Copy link
Copy Markdown
Owner Author

Defeito bloqueado pela revisão adversarial: sync_log.seq reusava número após deleção

O corte por sequência da lista de conflito supunha que sync_log.seq nunca reusa um valor já
emitido. Mas o sync_log tem deleções em produção:

  • diff-delete do re-import (google_sheets/import/mod.rs:539)
  • delete manual de transação (commands/transactions.rs:755)

O trigger original atribuía seq lendo MAX(seq) FROM sync_log a cada insert. Apagar as linhas
de maior seq derrubava esse MAX corrente — o próximo gesto inserido reusava um número já visto.
Cenário provado: âncora da base em seq = 5, um delete derruba o MAX corrente para 2, e o gesto
inserido depois da base recebia seq = 3 — abaixo da âncora, invisível na tela de conflito.

RED (antes do fix)

Dois testes novos em src-tauri/src/snapshot/conflict.rs, rodados ANTES da mudança na migration:

thread '...gestures_since_includes_a_gesture_inserted_after_a_delete_shrank_the_running_max' panicked:
assertion `left == right` failed: o gesto pós-base some da lista de conflito se o seq for reusado abaixo da âncora
  left: 0
 right: 1

thread '...restoring_a_vacuum_into_snapshot_keeps_the_sync_log_seq_watermark_monotonic' panicked:
o watermark sobrevive ao VACUUM INTO: ... (seq = 3)

seq = 3 reproduz exatamente o número da revisão.

Fix: rota (b), watermark nativo do SQLite (AUTOINCREMENT/sqlite_sequence)

A migration 20260814000002_sync_log_sequence.sql (ainda não mergeada — corrigida no lugar em
vez de empilhar outra) ganhou uma tabela geradora de uma linha só:

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 INTO sync_log_seq (n) VALUES (NULL);  -- DEFAULT VALUES não é aceito em corpo de trigger
    UPDATE sync_log SET seq = (SELECT n FROM sync_log_seq) WHERE rowid = NEW.rowid;
END;

sync_log_seq nunca guarda histórico (a linha é apagada antes de cada insert) — quem garante que
o próximo valor nunca repete é o AUTOINCREMENT do SQLite: o watermark real mora em
sqlite_sequence, que sobrevive a DELETE na tabela por definição.

Escolhi a rota (b) em vez do watermark manual em snapshot_state porque verifiquei, em vez de
supor, que sqlite_sequence viaja no VACUUM INTO
:

$ sqlite3 a.db "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT);
                INSERT INTO t(v) VALUES('x'),('y'),('z');
                DELETE FROM t WHERE v IN ('y','z');
                VACUUM INTO 'b.db';"
$ sqlite3 b.db "INSERT INTO t(v) VALUES('w'); SELECT * FROM t;"
1|x
4|w        -- nunca reusa 2 ou 3, mesmo no arquivo RESTAURADO

Com isso confirmado, AUTOINCREMENT é a rota mais idiomática: usa a garantia nativa do SQLite em
vez de reimplementar um contador monotônico manual em snapshot_state.

Teste de round-trip cobre exatamente esse caminho: popula 5 gestos, deleta os 3 últimos, exporta
via VACUUM INTO (a mesma função db_export::vacuum_into_atomic do check-in real), reabre o
arquivo exportado como o aparelho restaurado abriria o download, insere mais um gesto — o seq
resultante continua acima de 5, nunca reusa 3/4/5.

Prova (GREEN)

test snapshot::conflict::tests::gestures_since_includes_a_gesture_inserted_after_a_delete_shrank_the_running_max ... ok
test snapshot::conflict::tests::restoring_a_vacuum_into_snapshot_keeps_the_sync_log_seq_watermark_monotonic ... ok

test result: ok. 1399 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

npm run check inteiro verde (format, lint, typecheck, e2e:typecheck, vitest, build, rustfmt,
clippy, cargo test, privacy:scan, comment:hygiene, ui:audit) e npm run privacy:scan como comando
separado também verde.

Commit: cc872ad1b4be61970bd28ae16942ea6e83b87e71

@johnlaff

Copy link
Copy Markdown
Owner Author

Aviso para quem testou este branch localmente: a migration 20260814000002 foi corrigida no lugar (nunca chegou ao main), então um banco de dev que aplicou a versão anterior recusa abrir com "migration was previously applied but has been modified" — apague o banco local de dev e deixe as migrações recriarem.

@johnlaff
johnlaff merged commit 2d31963 into main Aug 14, 2026
9 checks passed
This was referenced Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Snapshot no Drive: robustez do check-out na abertura (deadline, republicação redundante, rollback do pool, rede engolida como NothingToDo)

1 participant