Offline + sincronização com backend¶
O que você vai aprender
Como ligar a fila offline do tempestweb (cliente) a um backend FastAPI
feito com o tempest-fastapi-sdk
para ter um app offline-first de ponta a ponta: o app enfileira mutações
localmente quando não há rede e sincroniza quando a conexão volta — sem
aplicar nada duas vezes. 🚀
A página PWA e offline mostra a fila offline do lado do cliente
(service worker, IndexedDB, native.offline). Aqui fechamos o círculo: o outro
lado da mutação é um servidor real que recebe, persiste e deduplica os
replays. Nada de novo no seu view() — só um endpoint bem construído do outro
lado. ✅
Por que offline-first¶
Um app tradicional escreve direto na rede: sem rede, a escrita falha e o usuário perde o que digitou. Offline-first inverte isso:
- Enfileira local primeiro. Toda mutação vai para uma fila durável no IndexedDB — a escrita "dá certo" na hora, mesmo sem rede.
- Sincroniza ao voltar a rede. Quando a conexão volta (evento
online, Background Sync ou replay explícito), a fila é drenada em ordem FIFO contra o backend. - Nunca aplica duas vezes. Cada mutação carrega uma chave de idempotência; o servidor deduplica, então reenviar uma mutação já aplicada é inofensivo.
Duas direções, dois mecanismos
- Push (escrita) — o app → servidor. É o que a fila
native.offlinefaz por você: enfileira, replay, idempotência. - Pull (leitura) — o servidor → app. Não há fila para isso; é um padrão de
delta-sync que você monta com
native.http+ o filtroupdated_at__gtdo repositório. Cobrimos os dois abaixo.
Arquitetura¶
┌──────────────────────── Browser (app tempestweb) ─────────────────────────┐
│ │
│ view() ──enqueue──► native.offline ──► OfflineQueue ──► IndexedDB │
│ (Python) (escrita durável) (FIFO + chave) (tempestweb- │
│ offline / │
│ mutations) │
│ │
│ online / Background Sync / replay() ──drena FIFO──► fetch │
│ header: │
│ idempotency-key │
└─────────────────────────────────────────────────────────────┬────────────┘
│ HTTPS
▼
┌──────────────── Backend (FastAPI + tempest-fastapi-sdk) ───────────────────┐
│ │
│ IdempotencyMiddleware ──(method, path, key)──► store (Memory | Redis) │
│ │ 1ª vez │ retry / replay → resposta em cache │
│ ▼ ▼ │
│ router ──► BaseRepository.add() ──► Postgres / SQLite │
│ (id, created_at, updated_at automáticos) │
│ │
│ pull: GET /api/notes?since=<ts> ──► repo.list({"updated_at__gt": ts}) │
└────────────────────────────────────────────────────────────────────────────┘
O ponto de encontro é a chave de idempotência: o cliente a envia no header, o
IdempotencyMiddleware do SDK a usa para cachear a resposta por
(método, caminho, chave). Um replay repetido devolve a resposta cacheada —
zero linha duplicada no banco.
Backend separado, de propósito
O backend é um serviço FastAPI próprio (o mesmo que você já teria com o
SDK). Ele não precisa saber que o cliente é um app tempestweb — recebe um
POST/PUT normal com um header de idempotência. Isso vale para os três modos
de execução do tempestweb (WASM, servidor, transpile).
Parte A — Enfileirar mutações no app tempestweb¶
A capacidade native.offline é a API real do cliente. Você enfileira uma mutação
em vez de bater na rede diretamente:
from tempestweb import native
async def save_note(text: str) -> None:
"""Enfileira a criação de uma nota (sobrevive a estar offline)."""
await native.offline.enqueue("POST", "/api/notes", {"text": text})
A assinatura completa:
await native.offline.enqueue(
method, # "POST" | "PUT" | "PATCH" | "DELETE"
url, # o endpoint do seu backend
body=None, # corpo JSON-able
*,
idempotency_key=None, # gerada automaticamente quando omitida
owner=None, # escopo opcional (ex.: por usuário)
)
O retorno é uma Mutation (id, owner, idempotency_key, method, url,
attempts, status). O status é "pending" até drenar; uma mutação que falha
de forma permanente vira "failed" (dead-letter) e um conflito 409 vira
"conflict" — nenhum dos dois trava a fila. Você também pode inspecionar e
drenar a fila:
| Chamada | Faz | Retorna |
|---|---|---|
native.offline.enqueue(...) |
Enfileira uma mutação durável. | Mutation |
native.offline.pending(owner=None) |
Lista as pendentes, mais antigas primeiro. | list[Mutation] |
native.offline.size(owner=None) |
Conta as pendentes. | int |
native.offline.replay(owner=None) |
Drena a fila agora (FIFO). | ReplayResult(sent, remaining, failed, conflicts) |
native.offline.failed(owner=None) |
Lista as mutações dead-lettered (falha permanente / tentativas esgotadas). | list[Mutation] |
native.offline.conflicts(owner=None) |
Lista as mutações paradas na lane de conflito (servidor devolveu 409). |
list[Mutation] |
O app completo¶
Um "log de atividade" mínimo: digitar e apertar Enfileirar grava na fila; o
contador vem de size(); Sincronizar dispara o replay (que o runtime também
faz sozinho ao voltar a rede). Este é o examples/offline-queue/app.py:
from __future__ import annotations
from dataclasses import dataclass, field
from tempest_core import App, Style, Widget
from tempest_core import Edge
from tempest_core import Button, Column, Input, Row, Text
from tempest_core import TextChangeEvent
from tempestweb import native
@dataclass
class QueueState:
"""Estado do demo de fila offline."""
draft: str = ""
queued: int = 0
status: str = ""
log: list[str] = field(default_factory=list)
def make_state() -> QueueState:
"""Estado inicial."""
return QueueState()
def view(app: App[QueueState]) -> Widget:
"""Input + contador de pendentes + controle de replay."""
def on_draft(event: TextChangeEvent) -> None:
value = event.value
app.set_state(lambda s: setattr(s, "draft", value))
async def queue_note() -> None:
text = app.state.draft
await native.offline.enqueue("POST", "/api/notes", {"text": text}) # (1)!
size = await native.offline.size()
def _update(s: QueueState) -> None:
s.queued = size
s.draft = ""
s.log = [*s.log, text]
s.status = f"enfileirado: {text}"
app.set_state(_update)
async def sync_now() -> None:
result = await native.offline.replay() # (2)!
def _update(s: QueueState) -> None:
s.queued = result.remaining
s.status = f"enviadas {result.sent}, faltam {result.remaining}"
app.set_state(_update)
return Column(
style=Style(gap=10.0, padding=Edge.all(16)),
children=[
Text(content=f"Pendentes: {app.state.queued}", key="pending"),
Row(
style=Style(gap=6.0),
children=[
Input(
value=app.state.draft,
placeholder="uma nota para sincronizar",
on_change=on_draft,
key="draft",
),
Button(label="Enfileirar", on_click=queue_note, key="queue"),
Button(label="Sincronizar", on_click=sync_now, key="replay"),
],
),
Text(content=app.state.status, key="status"),
],
)
- Enfileira em vez de dar
fetch. Funciona sem rede — a escrita vai para o IndexedDB. - Drena a fila agora. O runtime também faz isso sozinho quando a conexão
volta (evento
online+ Background Sync), então este botão é opcional.
A chave de idempotência é automática
Você não precisa gerar a chave: quando você omite idempotency_key, a fila
gera uma estável e a envia no header idempotency-key a cada replay. É essa
chave que o backend usa para não aplicar a mesma escrita duas vezes.
Parte B — O endpoint de sync no backend com o SDK¶
Do outro lado, um serviço FastAPI construído com o tempest-fastapi-sdk. Três
peças: o modelo, os schemas e o endpoint — mais o
IdempotencyMiddleware que faz o replay ser seguro.
O modelo e os schemas¶
O BaseModel do SDK já traz id (UUID), is_active, created_at e
updated_at — este último atualizado automaticamente a cada UPDATE, o que
é exatamente o que o delta-sync (Parte E) precisa.
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel, BaseResponseSchema, BaseSchema
class NoteModel(BaseModel):
"""Uma nota persistida."""
__tablename__ = "notes"
text: Mapped[str] = mapped_column()
class NoteCreateSchema(BaseSchema):
"""Corpo de criação de nota (o que o cliente enfileira)."""
text: str
class NoteResponseSchema(BaseResponseSchema):
"""Resposta de nota — herda id/is_active/created_at/updated_at."""
text: str
BaseRepository sem subclasse
Para CRUD simples você instancia o BaseRepository direto:
BaseRepository(session, model=NoteModel). Ele expõe add(), list(),
get_by_id(), update(), delete() e mais. Só faça uma subclasse quando
precisar de queries próprias.
O app FastAPI¶
from __future__ import annotations
from collections.abc import AsyncGenerator
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseRepository,
IdempotencyMiddleware,
MemoryIdempotencyStore,
register_exception_handlers,
)
db = AsyncDatabaseManager("sqlite+aiosqlite:///./notes.db")
app = FastAPI(title="notes-backend")
# Deduplica replays: um POST repetido com a mesma Idempotency-Key devolve a
# resposta cacheada — nenhuma linha duplicada.
app.add_middleware(
IdempotencyMiddleware,
store=MemoryIdempotencyStore(), # troque por RedisIdempotencyStore em multi-réplica
ttl_seconds=24 * 3600,
)
register_exception_handlers(app)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Uma sessão async por request."""
async for session in db.session_dependency():
yield session
@app.on_event("startup")
async def _startup() -> None:
"""Cria as tabelas no boot (em produção use migrations)."""
await db.create_tables()
@app.post("/api/notes", response_model=NoteResponseSchema, status_code=201)
async def create_note(
data: NoteCreateSchema,
session: AsyncSession = Depends(get_session),
) -> NoteResponseSchema:
"""Persiste uma nota. Idempotente via header Idempotency-Key."""
repo = BaseRepository(session, model=NoteModel)
note = await repo.add(NoteModel(text=data.text))
return NoteResponseSchema.model_validate(note)
Como a idempotência se conecta ponta a ponta
O native.offline replay envia o header idempotency-key; o
IdempotencyMiddleware lê Idempotency-Key — e headers HTTP são
case-insensitive, então os dois casam sem você fazer nada. O middleware só
age em POST/PUT/PATCH/DELETE e só quando o header está presente;
requests sem chave passam direto.
Parte C — Replay e confirmação ao voltar online¶
O replay é responsabilidade do runtime do cliente — o SDK não impõe protocolo de sync. O tempestweb drena a fila em três gatilhos:
- Evento
online. Assim que o browser reporta conectividade, a fila é drenada (aba aberta). -
Background Sync. Onde o navegador suporta (Chromium), o service worker drena a fila mesmo com a aba fechada. O
enqueueregistra a tag (tw-offline-replay) sozinho, então não há nada a ligar no seu app.Medido em Chrome real, com a aba fechada
Duas mutações enfileiradas offline, aba fechada, rede de volta: as duas saíram 1,01 s depois de a aba fechar e 3 ms depois do reconnect, com zero páginas da origem abertas, e a fila no IndexedDB ficou vazia. Até a 0.110.0 isso não acontecia: o worker alcançava os módulos da fila com um
import()dinâmico, que nenhum service worker pode fazer — a spec o proíbe noServiceWorkerGlobalScope— então todosynccaía no fallback de pingar clientes, e com a aba fechada não existe cliente para pingar. A fila ficava parada, em silêncio.- Explícito. O seu app chama
await native.offline.replay()quando quiser (ex.: um botão "Sincronizar" ou ao abrir a tela).
- Explícito. O seu app chama
O replay é FIFO e classifica cada falha, sem deixar a fila travar:
- Falha transitória (erro de rede,
5xx,408/425/429): a mutação fica pendente (comattemptsincrementado) e o replay para ali, preservando a ordem. Depois demaxAttemptstentativas (default 5) ela é dead-lettered (status="failed") e o replay segue drenando o resto. - Erro permanente (
4xxnão-retentável, ex.:400/422): dead-letter na primeira tentativa — não adianta reenviar um corpo que o servidor sempre rejeita, então ele não bloqueia a fila. - Conflito (
409): a mutação vai pra lane de conflito (status="conflict") para você reconciliar (Parte D); também não bloqueia.
Assim uma única "poison message" nunca prende todas as escritas atrás dela. O
ReplayResult te diz o resultado (sent, remaining, failed, conflicts):
from tempestweb import native
async def sync_and_report() -> str:
"""Drena a fila e devolve uma linha de status legível."""
result = await native.offline.replay()
if result.remaining == 0:
return f"tudo sincronizado ({result.sent} enviadas)"
return f"enviadas {result.sent}, {result.remaining} ainda pendentes"
O replay não devolve o recurso criado ao app
replay() retorna apenas contagens (sent / remaining), não os corpos
de resposta do servidor. Ou seja, o app não recebe de volta o id/created_at
da nota criada por uma mutação enfileirada. Se você precisa desses dados no
cliente, releia do servidor com um pull (Parte E) depois do replay — é o
padrão canônico offline-first (escreve otimista, reconcilia relendo).
Parte D — Idempotência e conflito¶
Idempotência (resolvido)¶
O IdempotencyMiddleware cacheia a resposta por (método, caminho, chave). Um
replay com a mesma chave devolve o mesmo resultado — nunca insere duas vezes.
É o padrão de Stripe/AWS/GitHub, e é o que torna o replay seguro por construção.
Escolha o store certo em produção
O MemoryIdempotencyStore é por processo — em multi-réplica uma réplica
não vê a chave cacheada por outra, e o replay pode duplicar. Com mais de uma
réplica use o RedisIdempotencyStore:
Conflito (decisão sua)¶
Idempotência resolve replay duplicado, não conflito de conteúdo (dois clientes editando a mesma linha). O modelo default da fila do tempestweb é last-write-wins: a última escrita a chegar vence. Para a maioria dos apps (notas, logs, formulários) isso basta.
Se você precisa de resolução por versão (rejeitar uma escrita baseada numa versão obsoleta), isso é uma decisão de aplicação — nem o tempestweb nem o SDK impõem um protocolo. Um padrão comum é versionar a linha e recusar escritas obsoletas:
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@app.put("/api/notes/{note_id}", response_model=NoteResponseSchema)
async def update_note(
note_id: str,
data: NoteUpdateSchema, # inclui um campo `version: int`
session: AsyncSession = Depends(get_session),
) -> NoteResponseSchema:
"""Atualiza uma nota rejeitando escritas baseadas numa versão velha."""
repo = BaseRepository(session, model=NoteModel)
note = await repo.get_by_id(note_id) # 404 se não existe
if data.version < note.version: # conflito → o cliente relê
raise HTTPException(status_code=409, detail="stale version")
note.text = data.text
note.version = data.version + 1
return NoteResponseSchema.model_validate(await repo.update(note))
Quando o servidor responde 409, o cliente não descarta nem reenvia em loop a
mutação: ela sai das pendentes e vai pra lane de conflito. O replay continua
drenando o resto da fila e o app reconcilia depois, lendo essa lane:
from tempestweb import native
async def reconcile_conflicts() -> None:
"""Relê e reconcilia as mutações que o servidor rejeitou com 409."""
for m in await native.offline.conflicts():
fresh = await native.http.request("GET", m.url) # relê o estado atual
... # merge/UI a seu critério
Honestidade sobre a fronteira
O trecho do endpoint acima é código de aplicação, não uma API do framework
— o campo version é seu. O tempestweb entrega a mutação de forma idempotente
e parqueia o 409 na lane de conflito para você; como reconciliar é você
quem decide (last-write-wins, versão, merge por campo, CRDT…).
Parte E — Pull: trazer as mudanças do servidor de volta¶
A fila cuida do push. Para o pull (o servidor tem dados novos que o app
ainda não viu), o padrão é delta-sync: guardar um watermark (o maior
updated_at que você já viu) e pedir só o que mudou desde então.
O BaseRepository suporta o filtro updated_at__gt para exatamente isso:
from datetime import datetime
from fastapi import Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
@app.get("/api/notes", response_model=list[NoteResponseSchema])
async def list_notes(
since: datetime | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[NoteResponseSchema]:
"""Lista as notas alteradas desde `since` (delta-sync)."""
repo = BaseRepository(session, model=NoteModel)
filters = {"updated_at__gt": since} if since else None
notes = await repo.list(filters=filters, order_by=NoteModel.updated_at)
return [NoteResponseSchema.model_validate(n) for n in notes]
No cliente, você lê o delta com native.http (não com a fila — leitura não
precisa de fila durável) e avança o watermark:
from tempestweb import native
async def pull_since(watermark: str | None) -> tuple[list[dict], str | None]:
"""Busca as notas alteradas desde `watermark`; devolve (notas, novo_watermark)."""
url = "/api/notes" + (f"?since={watermark}" if watermark else "")
response = await native.http.request("GET", url)
if not response.ok:
return [], watermark
notes = response.json_body or []
newest = max((n["updated_at"] for n in notes), default=watermark)
return notes, newest
O motor de pull (client/offline/pull.js)¶
Você não precisa montar o loop de delta-sync na mão: o cliente traz um motor
pronto (JS puro), espelhando o padrão do tempest-react-sdk.
createPull({ pullPage, applyRemote, watermark })— percorre as páginas desde o watermark seguindonextCursor, aplica cada linha em ordem e só avança o watermark pro últimoserverTimedepois de drenar tudo (um pull interrompido reprocessa do último ponto commitado). Single-flight, como a fila.createWatermark(key, storage)— watermark durável nolocalStoragecom fallback em memória.mergeRemoteInto(store, opts)— o merge canônico sobre aOfflineStore: last-write-wins, tombstone deleta, e guarda uma edição local ainda pendente se ela for estritamente mais nova que a linha do servidor (a escrita offline não-enviada sobrevive ao pull).
import { createPull, createWatermark, mergeRemoteInto } from "/client/offline/pull.js";
const pull = createPull({
pullPage: async (since, cursor) => {
const qs = new URLSearchParams();
if (since) qs.set("since", since);
if (cursor) qs.set("cursor", cursor);
const res = await native.http.request("GET", `/api/notes?${qs}`);
const body = res.json_body || {};
return { rows: body.rows, nextCursor: body.next_cursor, serverTime: body.server_time };
},
applyRemote: mergeRemoteInto(store), // store = createOfflineStore(...)
watermark: createWatermark("notes:watermark"),
});
await pull.pull(); // drena tudo desde o último watermark
Para juntar push + pull num só gesto com estado observável, use
createSyncController({ queue, pull }) (client/offline/sync-status.js):
syncNow() faz replay da fila e depois o pull, single-flight, e reflete
phase/pending/lastSyncedAt/error num store observável (subscribe) que a
sua view() pode renderizar. E installSyncBridge()
(client/offline/sw-bridge.js) liga as mensagens do service worker
(OFFLINE_PULL, OFFLINE_QUEUE_DRAINED) a esse controller — depois que o SW
drena a fila em background, a página (que tem o token) reconcilia com um pull.
Do Python: a capacidade native.sync¶
Não precisa tocar em JS: native.sync expõe tudo isso à sua view() (como
native.network faz com conectividade). Você configure uma fonte nomeada
(endpoint + tabela local, convenção GET <url>?since=&cursor= →
{rows, next_cursor, server_time}); o installSyncBridge é ligado sozinho na 1ª
configuração (o OFFLINE_PULL do SW reconcilia toda fonte configurada).
from tempestweb import native
await native.sync.configure("notes", "/api/notes", "app-db", "notes")
summary = await native.sync.now("notes") # replay da fila + pull
state = await native.sync.status("notes") # SyncState(phase, pending, last_synced_at, ...)
async for s in native.sync.watch("notes"): # stream do estado (T-EV)
app.set_state(lambda st: setattr(st, "sync", s))
Recap¶
- Push é resolvido pela fila.
native.offline.enqueue(...)grava local; o runtime drena FIFO ao voltar a rede (online+ Background Sync) ou viareplay(). Cada mutação carrega uma chave de idempotência. - A fila nunca trava. Falha transitória repete até
maxAttemptse então vira dead-letter (native.offline.failed()); erro permanente (4xx) é dead-lettered na hora;409vai pra lane de conflito (native.offline.conflicts()). Uma poison message não prende o resto. - O backend é FastAPI + SDK.
BaseRepository.add()persiste; oIdempotencyMiddleware(headerIdempotency-Key, case-insensitive) deduplica os replays por(método, caminho, chave)— zero linha duplicada. - Conflito é decisão sua. O default é last-write-wins; a lane de conflito
entrega o
409pra você reconciliar — versão/merge é código de aplicação. - Pull tem motor pronto.
createPull+mergeRemoteInto+createWatermark(client/offline/pull.js) fazem o delta-sync (watermark + cursor + LWW) sobre aOfflineStore— você só fornece opullPage(vianative.http+ o filtroupdated_at__gtdo repositório).createSyncControllerjunta push+pull com estado observável;installSyncBridgeliga o SW à página.
Pronto para publicar o backend? Veja o Deploy em produção e a Segurança (Modo B). 🚀