fix(campaign): C115-F1 — snapshot de eventos do motor Google Calendar com compare-and-swap (Closes #694)

- recordLastSeenSnapshot: update condicional (id + updatedAt) em vez de
  last-writer-wins; miss descarta a visão contaminada (never-throw)
- token CAS capturado no topo de runSyncPass: mesmo doc alimenta as
  decisões D2-D4 e o guard da escrita (fecha a janela basis-vs-token)
- teste int de corrida determinístico (promise gates): passada obsoleta
  não ressuscita remoção permanente (RED confirmado antes do guard)
- convenção de hermeticidade da janela documentada no header da spec
This commit is contained in:
Francisco Solla 2026-08-24 23:13:56 -03:00
parent d679708c06
commit a658a1025f
4 changed files with 374 additions and 7 deletions

View file

@ -0,0 +1 @@
**C115-F1 (#694) — corrida do snapshot de eventos com compare-and-swap + hermeticidade da suíte int documentada:** o snapshot `lastSeenEventIds` do motor Google Calendar era gravado last-writer-wins (`recordSyncState` recarrega e sobrescreve) — duas passadas sobrepostas (webhook vs hook de atividade em instâncias distintas) podiam fazer a visão antiga gravar por último, o id do evento sair do snapshot e a remoção permanente feita pelo usuário no Google virar recriação sem convergência. A gravação agora passa por `recordLastSeenSnapshot` (`googleCalendarSync.ts`), update condicional `where: { id, updatedAt = token }` capturado num reload NO TOPO de `runSyncPass` (depois do channel-ensure — cujas escritas fazem parte do baseline da própria passada — e antes das leituras que o snapshot resume): o MESMO doc alimenta as decisões D2–D4 (fecha também a janela basis-vs-token apontada no /simplify) e serve de guard CAS; em miss (`docs.length === 0`) a visão contaminada é descartada silenciosa (debug log, never-throw) e a passada seguinte cura sem recriar. Sem migration (o JSON não muda de forma); stamps `lastSuccessAt`/canal permanecem LWW (comutativos). Teste int novo coreografa a corrida de forma determinística (promise gates, zero sleeps): passada B estacionada dentro de `listEvents` com view pré-X, X nasce no meio, A completa e grava `{x}`, remoção permanente, B termina → CAS de B falha → terceira passada cancela (`created === 0`, `reverseEdits === 1`); RED confirmado contra o código anterior (recriava). Header de `googleCalendarSync.int.spec.ts` documenta a convenção F2 de hermeticidade (escopo via `activityWhere` com prefixo próprio; cleanup por teste nos irmãos já correto); helper compartilhado segue fora até a 2ª spec sensível à janela (DRY < 3). Débito único do triage: flip de linha de config entre specs paralelas (loader `limit:1` sem sort) — pré-existente, defer com gatilho no impl plan.

View file

@ -0,0 +1,246 @@
# Impl: C115 follow-up — corrida do snapshot de eventos + hermeticidade da suíte int do motor
Status: executado (2026-08-24)
Atualizado em: 2026-08-24
Issue: #694 (C115-F1)
Intenção: docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** duas passadas concorrentes do motor de sync Google Calendar não podem
fazer o snapshot `lastSeenEventIds` regredir (last-writer-wins hoje) a ponto de uma
remoção permanente feita pelo usuário ser "ressuscitada" como recriação de evento;
e a suíte int do motor para de absorver atividades ambiente de specs irmãs na janela
espelhada (estabilidade ×N sem falhas de contagem).
- **O que NÃO negociar:**
- Regra D4/C115 intacta: evento previamente visto que sumiu (`lastSeenIds.has(eventId)`
+ atividade `confirmado`) → cancelamento da atividade (`cancelActivityFromGoogle`),
nunca recriação (`src/utilities/googleCalendarSync.ts:569-584`). O fix só muda QUANDO
o snapshot é confiável para gravar, jamais a semântica de decisão.
- `ensureGoogleCalendarPushChannel` lê o MESMO snapshot para o pin de calendarId
(`googleCalendarSync.ts:678-690`) — essa leitura continua funcionando com estado
legado e novo, sem mudança de contrato.
- Motor never-throw: falha de gravação de estado vira degradação silenciosa
(próxima passada cura), nunca exceção nos gatilhos (hook de atividade
`src/utilities/googleCalendarSyncHooks.ts:26-44`, webhook
`src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts:97`,
hook de config L52-65).
- `overrideAccess: true` mantido nas escritas de estado (contexto sem `user`;
campos system-only por design — invariantes do engineering-brief).
- Sem migration: nenhuma coluna nova; o JSON `lastSeenEventIds`
(`src/collections/GoogleCalendarSync.ts:171-178`) nem muda de forma nesta abordagem.
- **O que reavaliar:** a hipótese da intenção de que o CAS precisaria comparar
`lastSuccessAt` — a implementação usa `updatedAt` do doc (token mais sensível e já em
mãos), mesmo mecanismo; e a hipótese de que F2 exigiria tocar specs irmãs — não exige
(ver F2 abaixo).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph pass["runSyncPass (uma passada)"]
A[load config\nupdatedAt = T0] --> B[lê atividades + lista remota]
B --> C[decisões D2/D3/D4\ncontra snapshot de T0]
C --> D{CAS: update ... where\nid = doc AND updatedAt = T0}
end
D -- "1 doc gravado (visão íntegra)" --> W[snapshot persistido]
D -- "0 docs (outro writer passou\nno meio da passada)" --> S[pula gravação\npróxima passada cura]
```
### Decisão F1 — corrida do snapshot
**Opções consideradas:** A) compare-and-swap otimista no write do snapshot |
B) advisory lock por passada (`postgresTransactionLocks`) | C) aceitar/documentar
**Recomendação: A — CAS otimista via update condicional, skip-silencioso no miss.**
A gravação do snapshot no fim de `runSyncPass` (`src/utilities/googleCalendarSync.ts`,
bloco `recordLastSeenSnapshot`) passa a ser condicional: o token (`updatedAt` + `id`)
é capturado num reload do doc NO TOPO de `runSyncPass` — depois do
`ensureGoogleCalendarPushChannel` (cujas escritas fazem parte do baseline da própria
passada; usar o `config` de L766 daria falso positivo determinístico em toda passada que
renovasse canal) e ANTES das leituras que o snapshot resume. A escrita é um
`payload.update({ where: { id, updatedAt = token } })`; `docs.length === 0` → outro
writer gravou durante a passada, esta visão está **contaminada** e é descartada
(never-throw, log debug).
Por que fecha o bug: a ressurreição exige que uma visão cega (B listou antes de X
existir) sobrescreva a visão de quem viu X. Com o guard, B só grava se NINGUÉM escreveu
estado desde o início da passada de B — exatamente a condição em que a visão de B é
confiável. Quem chega primeiro num estado limpo ganha; o segundo detecta a invalidação e
pula. Um snapshot temporariamente atrasado se auto-cura: se X existe remotamente mas não
no snapshot, a passada seguinte o vê na lista e o reinclui SEM recriar (o ramo de criação
só dispara quando X **não** está na lista remota). O caminho catastrófico (X fora do
snapshot E fora do Google) fica inalcançável por visão contaminada.
Escopo do CAS: **somente o write do snapshot**. Os outros stamps de
`recordSyncState` (`lastSuccessAt`/`lastError*` em `googleCalendarSync.ts:800,805`,
canal em L672/687/715/737) permanecem last-writer-wins — são carimbos comutativos e
benignos; serializá-los seria cerimônia sem volatilidade. O doc carregado em
`loadGoogleCalendarSyncConfig` (find depth 0, `googleCalendarSync.ts:160-181`) já traz
`id` e `updatedAt` — nenhum campo novo, nenhuma migration, nenhum módulo novo (edita-se
o owner `googleCalendarSync.ts`).
**Alternativas rejeitadas:**
- **B) advisory lock por passada** (`acquireTextAdvisoryLocks`,
`src/utilities/postgresTransactionLocks.ts:40-61`): `pg_advisory_xact_lock` exige
transação aberta durante toda a passada (`withPayloadTransaction` envolvendo
`listEvents`/`insertEvent`/`updateEvent`/`watchEvents` — chamadas externas lentas ao
Google). É exatamente o hazard documentado no precedente do instagramSync
(`src/utilities/socialFeed/instagramSync.ts:59-79`): transação longa segurando row
lock/conexão do pool, deadlock potencial com hooks escrevendo o mesmo doc, e gatilhos
fire-and-forget (hook de atividade dentro do save do usuário) bloqueando atrás de
I/O de rede. Força demais para proteger UMA escrita de uma linha.
- **C) aceitar/documentar**: rejeitado na triage da intenção — write path, piso de score.
- **Merge monotônico por `listedAt` dentro do JSON** (sub-variante considerada): capturar
o instante pós-`listEvents` no JSON e só aceitar escrita com observação mais nova.
Rejeitada: não fecha o caso da observação cega (a lista vazia de B pode ser
genuinamente MAIS NOVA que a de A e ainda assim não ter visto X — B começou antes);
comparação de relógio entre writers é frágil; e mudaria a forma do JSON consumido
também pelo pin do canal. O guard por `updatedAt` invalida a passada inteira, cobrindo
também a leitura de atividades (mais forte e mais simples).
- **Retry completo da passada no miss**: re-listar e regravar adiciona loop/livelock em
potencial e custo de I/O externo; o skip é seguro porque a cura acontece naturalmente
na próxima passada (ver acima). Complexidade sem ganho de correção.
### Decisão F2 — hermeticidade da suíte int
**Opções consideradas:** A) cleanup por teste nos irmãos | B) helper compartilhado de
"atividades da janela com prefixo" | C) documentar a convenção
**Recomendação: C — documentar a convenção no header de
`tests/int/googleCalendarSync.int.spec.ts`.** O mecanismo de isolamento JÁ EXISTE e já
é usado: `fixtures.own` limpa por teste (beforeEach/afterEach em
`tests/helpers/campaignFixtures.ts:1115-1146`), então não há vazamento *entre testes* —
o problema real é coexistência *durante* execução paralela (vitest até 8 forks num único
`teqo_test`, `vitest.config.mts:18-22`), e contra isso a única defesa válida é o
consumidor da janela se escopar — o que a spec do motor já faz com
`activityWhere {title like 'C114%'}` (`googleCalendarSync.int.spec.ts:120-130`, mecanismo
C126 do próprio motor, `googleCalendarSync.ts:752-758`). Cleanup adicional nos irmãos
(option A) não resolve coexistência e toca specs alheias sem ganho comportamental.
Helper compartilhado (option B) tem HOJE um único call site — extrair é DRY < 3 call
sites (rabbit hole nomeado); o gatilho de revisitação é a segunda spec sensível à janela.
**Alternativas rejeitadas:** A porque edits specs irmãs (`homeSearchActivities`,
`calendarFeed`, `campaignActivity` — todas criam atividades confirmadas na janela via
`createActivityRecord`, ex. `homeSearchActivities.int.spec.ts:48-53`) sem eliminar a
coexistência paralela, que é a raiz; B porque abstração prematura com 1 call site —
documentação + `activityWhere` existente cobre o risco a custo zero.
### Componentes / mudanças
- **`runSyncPass` / novo write condicional do snapshot** (`src/utilities/googleCalendarSync.ts`):
substitui a chamada plain de `recordSyncState` do snapshot (L587-592) por um
`payload.update` com `where` composto (`id` + `updatedAt = config.updatedAt`),
`depth: 0`, `overrideAccess: true`, `req` repassado; `docs.length === 0` → return
silencioso. `recordSyncState` permanece intocado para os demais patches.
- **`createStubClient`** (`tests/int/googleCalendarSync.int.spec.ts:35-58`): ganha gate
opcional injetável (promise deferred aplicada ao `listEvents`) — default indefinido,
specs existentes intocadas. Stub de `insertEvent` torna-se idempotente por id
(ignora inserção de id já presente), imitando a rejeição de id duplicado do Google —
necessário para a coreografia da corrida.
- **Migration:** sem migration (nenhuma coluna/field novo; o JSON não muda de forma).
- **Access / Consent:** nada novo; `overrideAccess: true` preservado e documentado no
código (contexto sem user, campos system-only — invariante atendida).
- **UI:** nenhuma.
### Dados → forma (se aplicável)
Não se aplica (nenhuma superfície de dados nova exposta; o snapshot mantém a forma atual).
## Fases verificáveis
1. **Tracer F1 — guard CAS no snapshot** (~metade do appetite): em
`googleCalendarSync.ts`, trocar o write plain do snapshot pelo update condicional
(skip em 0 docs). Verificar: `pnpm test:int -- tests/int/googleCalendarSync.int.spec.ts`
verde (comportamento sequencial idêntico — o guard só difere sob concorrência) +
`pnpm gate:fast`.
2. **Teste de corrida F1 (RED→GREEN)**: gate no stub + novo teste
"overlapping passes do not resurrect a permanent removal": coreografia determinística
por promises (sem sleeps) —
(i) cria config; inicia passada B com cliente cujo `listEvents` espera num gate
(as atividades de B já foram lidas — `loadSyncActivities` precede `listEvents`,
`googleCalendarSync.ts:458-460`);
(ii) cria a atividade (hook dispara passada real sem credencial → early-return
inofensivo);
(iii) passada A roda livre: lista vazia → insere X → grava snapshot {x};
(iv) simula remoção permanente do usuário: remove X do store;
(v) libera o gate de B: B lista vazio, não conhece a atividade, grava snapshot {}
— no código atual ressuscita; com o guard, o CAS de B falha e pula;
(vi) terceira passada: assert `created === 0`, `reverseEdits === 1`, store vazio,
atividade `cancelado`. Rodar ANTES do passo 1 aplicado para ver o RED (cria o evento),
depois GREEN. Verificar: arquivo alvo verde.
3. **F2 — convenção documentada**: bloco de comentário no header de
`tests/int/googleCalendarSync.int.spec.ts`: specs novas sensíveis à janela espelhada
DEVEM escopar via `options.activityWhere` (prefixo próprio de título), e specs que
criam atividades de passagem mantêm cleanup por teste via `installCampaignFixtures`.
Sem código de produção. Verificar: lint verde.
4. **Gates e estabilidade**: rodar o arquivo alvo ×5 local (`pnpm test:int --
tests/int/googleCalendarSync.int.spec.ts`, repetido) e a suíte int completa 1×
(`pnpm test:int`) sem falhas de contagem; `pnpm gate:fast`; PR com cascade normal
(CI roda o conjunto curado conforme ci-scope); push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Fila/job framework ou serialização global de passadas — o CAS torna a ordem
irrelevante para a correção do snapshot; serializar é option B rejeitada.
- Retry/backoff no miss do CAS — skip + cura na próxima passada basta.
- Generalizar CAS para todos os patches de `recordSyncState` — só o snapshot tem
semântica de leitura-modificação-escrita; stamps são comutativos.
- Extrair helper de harness de sync (F2 option B) — 1 call site hoje; revisitar na
segunda spec sensível à janela.
- Tocar specs irmãs (`homeSearchActivities`, `calendarFeed`, `campaignActivity`) —
cleanup delas já está correto por teste.
- Corrida de dupla-insersão de evento (duas passadas criando o mesmo id determinístico
simultaneamente) — mitigada no stub do teste por idempotência; no Google real o id
determinístico rejeita duplicata e a convergência por conteúdo (D3) cura. Fora do
aceite deste follow-up.
- **Flip de linha de config na suíte int** (achado do /simplify desta sessão):
`loadGoogleCalendarSyncConfig` é `limit: 1` sem sort e
`googleCalendarSyncAction.int.spec.ts` cria rows próprias na mesma DB compartilhada —
pré-existente, nunca observado como flake, e a leitura "configured row wins" já é o
contrato do motor. Defer com gatilho: primeiro flake atribuído a flip de row de
config ou uma terceira spec criando rows de `googleCalendarSync`.
## Riscos e mitigação
- **Semântica do `payload.update` por `where`** (verificado no fonte do adapter
drizzle 3.82: o bulk update faz find-com-`where` e depois update por id —
check-then-write, não um único UPDATE atômico): a janela residual é de
milissegundos contra passadas de segundos; o teste de corrida valida o
comportamento end-to-end. Se um dia essa janela importar, o fallback é
advisory lock curto em volta de re-ler+gravar (sem I/O externo dentro).
- **Falso positivo do guard** (escrita não-relacionada — renovação de canal, erro do
webhook — invalida a passada): consequência é apenas pular a gravação do snapshot;
cura automática na passada seguinte (X presente remotamente volta ao snapshot sem
recriação). Degradation, never incorrect.
- **Flakiness do teste de corrida**: coreografia por promises explícitas (gates), sem
dependência de timing; timeout global de 10s da suíte é folgado para 3 passadas stub.
- **Precisão de `updatedAt`** (duas escritas no mesmo microsecond): improvável no
Postgres (`timestamptz` microsecond); no pior caso o guard falha a valer → skip
conservador → cura. Nunca grava visão contaminada.
- **Passadas disparadas por hook dentro da transação do save** (`req.transactionID`
presente): o update condicional participa da transação do caller como qualquer write
de hoje — nenhuma nova semântica introduzida (ao contrário da option B, que abriria
transação própria em volta de I/O externo).
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (teste de corrida da fase 2 é
literalmente o aceite: remoção permanente após corrida sobreposta não é
ressuscitada; suíte ×N sem falhas de contagem)
- [x] Invariantes AGENTS/engineering-standards (sem migration; overrideAccess
preservado e justificado; edita-se o owner, sem twins; identificadores em
inglês, copy pt-BR)
- [x] Testes de domínio previstos (int com stub: corrida + estabilidade ×N) onde o
write path muda
## Self-score (decision-quality, gate ≥4)
| Dimensão | Nota | Justificativa |
| --- | --- | --- |
| Decisões caras têm rejeitadas? | 5 | CAS vs advisory-lock vs aceitar vs merge-por-listedAt vs retry — cada uma com motivo técnico citando precedentes do repo. |
| Abordagem cabe no appetite? | 5 | Guard ~20 linhas no owner + 1 teste de corrida + 1 bloco de comentário; bem dentro de 0,5–1 dia. |
| Rabbit holes nomeados? | 5 | Seis itens explícitos (fila, retry, generalização do CAS, helper F2, specs irmãs, dupla-insersão). |
| Depth check: reusa o que existe? | 4 | Edita o owner `googleCalendarSync.ts`, reusa `activityWhere`/fixtures/stub existentes; perde 1 pois o stub ganha mecanismo novo de gate (justificado: único jeito determinístico de coreografar a corrida). |
| Intenção permanece satisfeita? | 5 | Outcome e regra D4 intactos; engenharia escolheu forma (guard por `updatedAt` vs `lastSuccessAt`) sem reescrever o aceite. |

View file

@ -466,6 +466,11 @@ const runSyncPass = async (
activityWhere: Where | undefined,
config: GoogleCalendarSyncDoc,
): Promise<SyncCounts> => {
// C115-F1 — captured AFTER the channel ensure (its own writes are part of
// this pass's baseline) and BEFORE reading the inputs it summarizes. This
// ONE read is both the decision basis (snapshot parsed below) and the CAS
// guard of the final write — full rationale on recordLastSeenSnapshot.
const casToken = await loadGoogleCalendarSyncConfig(payload, req)
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const { activities, municipalityNames } = await loadSyncActivities(payload, req, activityWhere)
const listWindow = buildListWindow(rangeStart, rangeEnd)
@ -478,7 +483,11 @@ const runSyncPass = async (
activityWhere,
)
const snapshot = parseLastSeenSnapshot(config.lastSeenEventIds)
// The CAS token doc IS this pass's consistent view — the same read feeds
// the decisions and the guarded write. Deciding on the outer `config`
// instead would let a writer between the two loads leave this pass on a
// stale snapshot while it still wins the CAS (resurrection window).
const snapshot = parseLastSeenSnapshot((casToken ?? config).lastSeenEventIds)
const lastSeenIds =
snapshot?.calendarId === calendarId ? new Set(snapshot.ids) : new Set<string>()
@ -596,11 +605,9 @@ const runSyncPass = async (
created += 1
}
await recordSyncState(payload, req, {
lastSeenEventIds: {
calendarId,
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
},
await recordLastSeenSnapshot(payload, req, casToken, {
calendarId,
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
})
return { created, updated, deleted, reverseEdits }
@ -613,7 +620,6 @@ type SyncStatePatch = Partial<
| 'lastSuccessAt'
| 'lastErrorAt'
| 'lastError'
| 'lastSeenEventIds'
| 'pushChannelId'
| 'pushChannelResourceId'
| 'pushChannelExpiresAt'
@ -622,6 +628,52 @@ type SyncStatePatch = Partial<
>
>
/**
* C115-F1 — records the last-seen snapshot behind an optimistic
* compare-and-swap. The snapshot is READ-MODIFY-WRITE state: two passes can
* overlap (webhook vs activity hook on separate instances), and a stale pass
* recording LAST erases ids it never saw — the user's permanent removal then
* looks "never created" and the next pass rebuilds the event, un-doing the
* removal for good. The write therefore lands only while the sync row still
* carries the `updatedAt` this pass observed before reading its inputs
* (`casToken`, captured at the top of `runSyncPass` — that same doc is also
* the decision basis, keeping decisions and write on one consistent read).
* On a miss the stale view
* is dropped (debug-logged) — never-throw contract: the winner's fresher
* snapshot stands; a merely lagging one self-heals next pass (a present
* event is re-listed, not rebuilt). Residual window: the conditional update
* matches at query time (find-then-update), milliseconds wide against
* passes that run for seconds.
*/
const recordLastSeenSnapshot = async (
payload: Payload,
req: PayloadRequest | undefined,
casToken: GoogleCalendarSyncDoc | null,
snapshot: GoogleCalendarSyncSnapshot,
): Promise<void> => {
if (!casToken) return // the row vanished mid-pass — nothing to record onto
const result = await payload.update({
collection: 'googleCalendarSync',
where: {
and: [{ id: { equals: casToken.id } }, { updatedAt: { equals: casToken.updatedAt } }],
},
data: { lastSeenEventIds: snapshot },
depth: 0,
// Intentional admin bypass: the state fields are system-only by design.
overrideAccess: true,
req,
})
// CAS miss (docs.length === 0): another writer recorded state mid-pass —
// this pass's view lost the race and its snapshot is intentionally dropped.
if (result.docs.length === 0) {
payload.logger.debug(
'googleCalendarSync: snapshot descartado — outra passada gravou estado durante esta passada.',
)
}
}
const recordSyncState = async (
payload: Payload,
req: PayloadRequest | undefined,

View file

@ -1,4 +1,21 @@
// @vitest-environment node
//
// C115-F1 — window hermeticity convention (read before adding tests here):
// this engine mirrors the WHOLE activity window by design, and the int suite
// runs files in parallel against ONE shared database — sibling spec files
// keep activities inside that window while they run. Two rules keep counts
// stable without touching other specs:
//
// 1. Any NEW spec here that asserts on mirror counts MUST scope its passes
// with its own title prefix via `activityWhere` (this file uses
// `{ title: { like: 'C114%' } }`) — never count on an empty window.
// 2. Specs elsewhere that create pass-through activities in the window
// keep the per-test cleanup from `installCampaignFixtures` (fixtures
// are owned and removed beforeEach/afterEach) — never leak rows past
// the test that created them.
//
// A shared "window-safe fixture" helper stays OUT until a second spec needs
// it (DRY < 3 call sites).
import { getPayload, type Payload } from 'payload'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
@ -526,6 +543,57 @@ describe('campaign Google calendar sync engine (C114)', () => {
expect((await reloadActivity(activity.id)).status).toBe('cancelado')
})
it('an overlapping stale pass does not resurrect a permanent removal (snapshot CAS)', async () => {
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const stub = createStubClient(store)
// Pass B mirrors the webhook-vs-hook overlap: it loads its activity
// view BEFORE X exists, then parks inside `listEvents`. The park is
// awaited so the staleness is deterministic — never timing-dependent.
let parkB!: () => void
let releaseB!: () => void
const parked = new Promise<void>((resolve) => {
parkB = resolve
})
const gate = new Promise<void>((resolve) => {
releaseB = resolve
})
const clientB: GoogleCalendarClient = {
...stub,
listEvents: async (calendarId, range) => {
parkB()
await gate
return stub.listEvents(calendarId, range)
},
}
const passB = runSync(clientB)
await parked
// X is born mid-flight (its own hook pass runs credential-less: no-op).
const activity = await createActivity()
// Pass A completes cleanly: creates x and records it as seen.
const outcomeA = await runSync(stub)
expect(outcomeA.created).toBe(1)
// The user permanently removes x from Google…
store.splice(0, 1)
// …and B — whose view predates X — finishes LAST. Erasing x from the
// last-seen snapshot here is what resurrects it on the next pass.
releaseB()
const outcomeB = await passB
expect(outcomeB.status).toBe('synced')
// The removal stands: previously seen & gone cancels, never re-creates.
const outcomeC = await runSync(createStubClient(store))
expect(outcomeC.created).toBe(0)
expect(outcomeC.reverseEdits).toBe(1)
expect((await reloadActivity(activity.id)).status).toBe('cancelado')
expect(store).toHaveLength(0)
})
it('a failed creation is never "seen" — the next pass creates instead of cancelling', async () => {
const activity = await createActivity()
await createConfig(calendarA)