Linear Sync Engine · arquitetura para quem vai construir ou revisar

Um mirror read-heavy do Linear, com UI própria sobre um contrato RPC único

O sistema mantém uma cópia de 11 entidades do Linear num Postgres nosso. A cópia é alimentada por webhooks onde existem e por polling de reconciliação onde não existem. Uma UI web e um shell desktop leem só do mirror, através de um RpcGroup tipado em Effect v4. Escritas são raras e passam síncronas para o Linear. O Linear é a fonte de verdade em qualquer conflito.

CoreEffect 4.0.0-rc.112 (hard pin)
Runtime do ingestNode 22.16+, @effect/platform-node
StorePostgres (@effect/sql-pg); PGlite/SQLite em teste
UITanStack Start, React 19, @effect/atom-react
DesktopTauri 2.11.5, shell puro
TransporteWebSocket RPC (ndjson); HTTP ndjson fallback

Por que não construir a UI direto na API do Linear

Quem escreve no workspace é um time de agentes. O dono precisa ler o que eles fizeram, com uma view model própria, em tempo real. Três fatos da API do Linear impedem fazer isso direto na UI:

  • Rate limit por usuário. API key: 2.500 req/h e 3.000.000 pontos/h. OAuth: 5.000 req/h e 2.000.000 pontos/h. Teto de 10.000 pontos por query. Cada tela aberta consumindo a API direto multiplica o gasto.
  • Sem realtime público. Subscriptions GraphQL existem no schema, mas não são documentadas nem suportadas. O único canal push é webhook.
  • Quatro entidades sem webhook. Organization, Team, WorkflowState e ProjectMilestone só mudam por polling. Colunas de board dependem de WorkflowState.

Sem um mirror, cada tela reimplementa paginação, rate limiting, filtro e controle de staleness. Com o mirror, isso vive num lugar só.

Topologia

Dois deployables e um wrapper. Processo único no ingest: PubSub em memória, rate limiter com layerStoreMemory, Persistence.layerSql. Sem escala horizontal nesta fase.

Linear GraphQL + webhooks apps/ingest Node 22, Effect v4, processo único webhook route reconcile loops PersistedQueue RpcServer (WS) Postgres: mirror + deliveries + sync_state apps/web SPA, TanStack Start apps/desktop Tauri, mesma SPA, zero commands webhook POST GraphQL (poll, write-through) RpcGroup sobre WSS

A UI nunca chama a API do Linear. O único cliente do Linear é o ingest. O mount in-process do RPC dentro do Start existe só como modo dev.

Modelo de dados

Fatia core: 11 entidades. Organization, Team, User, WorkflowState, IssueLabel, Project, ProjectMilestone, Cycle, Issue, Comment, Attachment. Uma tabela por entidade via Model.Class.

Mirror híbrido. Colunas tipadas para o que é consultado ou filtrado, mais uma coluna raw JSONB com o payload inteiro. Três camadas declaradas separadamente: row schema, connection schemas, derived fields (computados no servidor, nunca coluna).

Multi-tenant desde o início. organization_id em toda tabela, payload e reactivity key. Verifica assinatura primeiro, discrimina tenant depois.

Upsert com version guard, last-write-wins.
ON CONFLICT (id) DO UPDATE SET … WHERE mirror.updated_at < EXCLUDED.updated_at. Nunca combinar .returning() com ON CONFLICT no mesmo template.
Regra de schemaPor quê
id é Model.GeneratedByAppOs ids do Linear são UUID v4 gerados fora do nosso banco. GeneratedByDb omite o campo das variantes insert/update e quebra todo upsert.
linearUpdatedAt é Schema.StringModel.DateTimeUpdate sobrescreve com now a cada update e destrói o version guard.
Soft delete em archivedAt, trashed, deletedAtNunca hard-delete. Bootstrap passa includeArchived: true. Bursts de auto-archive não perdem linhas.
Nenhum Schema.Literal fechado sobre campo que o Linear tipa como StringEnums com escape hatch. Um valor novo no Linear não pode derrubar o decode.
Attachments: só metadataURL assinada derivada na leitura via public-file-urls-expire-in. Nunca armazenada.
IssueHistory lazyCarregado ao abrir o detalhe, cache em coluna raw própria com TTL. Backfill completo queimaria o budget.
Migrations com id YYYYMMDDHHmmssO loader falha explicitamente se id ≤ último aplicado. O Migrator padrão pularia em silêncio.

Ingest: webhook, reconciliação e rate limit

Caminho do webhook

  1. 1

    Verificar HMAC-SHA256. timingSafeEqual após guard de tamanho, janela de ±60 s no timestamp. Assinatura inválida incrementa webhook_signature_invalid_total.

  2. 2

    Persistir a entrega cru. Tabela de deliveries com PK delivery_id e índice único em (entity_type, entity_id, action, webhook_timestamp).

  3. 3

    Responder 200 imediatamente. O Linear exige resposta em 5 s e faz no máximo 3 retries (1 min, 1 h, 6 h) antes de poder desativar o webhook. Um alarme dispara se isso acontecer.

  4. 4

    Aplicar via PersistedQueue. Chave de idempotência derivada do conteúdo: type:data.id:data.updatedAt. Sem unstable/workflow: é uma camada sobre PersistedQueue e só é durável com cluster.

  5. 5

    Decodificar e normalizar. Família de schemas *WebhookPayload separada da GraphQL (32 diferenças de tipo). Decode pelo header Linear-Event primeiro. Router de mundo aberto com quarentena. Falha de decode responde 500 (retry), nunca 400.

  6. 6

    Upsert com version guard e publicar reactivity keys. Object.keys(updatedFrom) é o conjunto de colunas alteradas. Valores novos sempre vêm de data; updatedFrom traz os valores anteriores, não um partial update.

Reconciliação

Dois tiers mais um adaptativo. Tier frequente para as quatro entidades sem webhook. Tier de segurança para issues. O intervalo adapta por Query.rateLimitStatus.

Cursor defensivo: updatedAt gte com ≥ 60 s de overlap, orderBy: updatedAt explícito, includeArchived: true explícito, dedupe por id no sink, lastSync gravado na mesma transação do batch em sync_state. Schedule: Schedule.spaced(...).pipe(Schedule.jittered).

Subscriptions GraphQL: uma sonda registrada (S-08). Poll é a base.

Rate limit

Dois buckets RateLimiter por token: req/h e pts/h. tokens = complexidade estimada. delay = máximo dos dois. Seed único no cold start por rateLimitStatus; re-seed em qualquer RATELIMITED (HTTP 400 com errors[].extensions.code). X-Complexity da última resposta estima a próxima chamada. Sem adaptiveConsume.

Envelope do Linear: ler errors[] primeiro, porque há sucesso parcial sob HTTP 200. Retryable: network, internal, lock timeout, ratelimited. Não retryable: invalid input, user error, feature not accessible, forbidden, usage limit exceeded. graphql error = drift de schema, alerta.

Contrato RPC: a única superfície

Sem SSE, sem HttpApi. Um RpcGroup é o único wire entre UI e ingest. Sub-grupo por fatia de domínio, arquivo próprio, RpcGroup.merge na raiz, middleware aplicado depois do merge. Um teste afirma o tamanho do grupo mesclado, porque merge sobrescreve tag duplicada sem erro e .middleware() só cobre os RPCs já adicionados.

Um stream: true por superfície viva. Snapshot RPC só onde a UI precisa de leitura pontual. stream: true força errorSchema = Schema.Never, então stream e snapshot nunca compartilham declaração de erro.

Erros. Um Schema.TaggedError por modo de falha, junto de quem levanta. Uniões por sub-grupo derivadas. Três buckets explícitos: error tipado, defect tipado, die.

Transporte. WebSocket com supportsAck: true como default. HTTP ndjson como fallback registrado. RpcSerialization.layerNdjson nos dois lados, nunca layerJson em stream. Presets nomeados exportados pelo pacote: socket, http-ndjson, in-process.

Parâmetros congelados do servidor. concurrency: 16, streamBufferSize: 64, highWaterMark: 4 MiB. Mount por layerProtocolSocket + layer({ concurrency }). Recalibrar depois do bootstrap.

Reactivity keys, convenção congelada. Query: ["Issue"] para tabela, ["Issue:" + id] para linha. Mutation: { Issue: [id1, id2] }. Prefixo = nome do Model.Class. serializationKey = a mesma string. Nunca passar objeto como elemento do array de chave.
Contrato diffável. Tipos + tstyche + contract/wire-schema.json gerado de group.requests e diffado no CI. Dois agentes descobrem divergência antes da integração.

Frontend

SPA em todo lugar. Sem SSR. TanStack Start com React 19 e @effect/atom-react. React 19 é obrigatório: o binding declara peer react >=19 <20.

Regras que valem

  • Dados entram só por loaders do Router, que aguardam atoms via Registry.getResult(context.registry, atom). AtomRegistry vive em router.context. loaderDeps vem de search params validados.
  • Router é dono de todo parâmetro de URL. Filtro e estado de view vivem na URL.
  • Coleção do cliente via runtime.atom(Stream.scan(...)). Poll unário como fallback.
  • Mutação otimista com Atom.optimistic e rollback em erro tipado. Criação de issue com UUID gerado no cliente: nunca troca de id depois.
  • Resync em reconnect e em visibilitychange. Um feed silenciosamente stale é bug.
  • <RegistryProvider> monta em Wrap. tanstackStart() antes de viteReact(). getRouter é nome load-bearing.

Proibido, com lint

  • fetch, RpcClient ou AtomRpc.query fora de loaders e atoms. Regra de lint bloqueia.
  • TanStack Query. Atom.swr + Reactivity já dão cache e invalidação.
  • TanStack DB. Beta, sem devtools, bug de startup em Tauri.
  • Atom.searchParam. Escreve em pushState e briga com o Router.
  • Server functions do Start como costura de dados. URL relativa e CSRF same-origin quebram sob Tauri.
  • export de componente em arquivo de rota. Arquivos separados usam getRouteApi('/path').

Stack de UI: shadcn/ui + Radix, Tailwind v4, @tanstack/react-virtual@3.14.10 para a lista de 10k linhas, TanStack Form v1 1.33.5 com Schema.toStandardSchemaV1. Filtros numa DSL própria, traduzida para SQL local e codificada para IssueFilter quando vai ao Linear.

Scaffold determinístico: npx gitpick TanStack/router/tree/main/examples/react/start-basic apps/web. create @tanstack/start não existe.

Desktop

Tauri 2.11.5 como shell puro. A webview abre o RpcClient direto contra o ingest público. Sem sidecar, sem réplica local, zero #[tauri::command]. O contrato RPC é o único IPC.

Build: tanstackStart({ spa: { enabled: true, prerender: { outputPath: '/index.html' } } }), frontendDist no output do cliente, devUrl no dev server. Seleção de modo por TAURI_ENV_PLATFORM no vite.config.ts, não por isTauri().

apps/desktop/SECURITY-CONTRACT.md congelado antes do dispatch: origens da webview por plataforma (tauri://localhost no macOS, http://tauri.localhost no Windows), string de connect-src (mínimo ipc: http://ipc.localhost wss://<ingest> https://<ingest>), capabilities, allowlist de CORS no servidor. CSP restritiva desde o primeiro ticket.

CI só macos-latest aarch64 no primeiro ticket. Assinado e notarizado. Updater por latest.json estático. Versão única do app derivada de tag git, compartilhada com a imagem do ingest.

Limites conhecidos do Tauri que moldaram a decisão. A webview suspende após ~5 min oculta em Linux, Windows e Android. Sidecar não existe em mobile. Android não é secure context sem useHttpsScheme. Por isso mobile ficou fora, o core vive remoto e o resync em visibilitychange é requisito.

Auth e config

OAuth actor=user com PKCE para tudo que é user-facing. O usuário vê só o que o Linear deixa. actor=app (client credentials) confinado a reconciliação e write-through.

Access token vive 24 h. Refresh tem 30 min de graça. O token store faz replay dentro da janela e nunca trata refresh falho como logout. O desktop completa o fluxo sem client secret no binário.

Times privados fora: só allPublicTeams: true. CustomView ignorado, o app tem sua própria view model.

ConfigLadoVariáveis
SegredosservidorLINEAR_OAUTH_CLIENT_ID, LINEAR_OAUTH_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET (+ secundário na rotação), DATABASE_URL, OTLP_ENDPOINT, OTLP_HEADERS
ClientebundleVITE_RPC_URL (absoluta), opcional VITE_OTLP_ENDPOINT
Qualquer Config sem prefixo VITE_ alcançável pelo bundle do cliente é bug de segurança.

Gate e testes

Gate é só unit: sete jobs offline. Nada no gate toca a API real do Linear.

JobO que prova
lintoxlint + oxfmt. oxlint-plugin-effect@0.12.0 pinado, outdatedApi do tsgo, oxlint-tsgolint para regras type-aware. Sem eslint, prettier ou dprint.
typesTS 7 (@typescript/native) + @effect/tsgo. TS 6 ao lado como escape hatch.
types-contracttstyche com --target '>=5.9 <=6.0' sobre o contrato.
test@effect/vitest@4.0.0-rc.112, RpcTest.makeClient, Layer.mock, it.layer(...), TestClock, fake-indexeddb, vitest-websocket-mock.
contract-freshnesswire-schema.json regenerado bate com o commitado.
circularSem import circular entre pacotes.
dedupe-effectUma única cópia de effect e @effect/* no lockfile, na versão pinada.

Fora do gate: e2e, tauri-e2e, integration-linear (um job serializado contra workspace Free), bundle-compare (só em PR).

Doubles: mock/* via Layer.mock; faker com graphql-faker sobre o SDL pinado; sig com signWebhook(payload, secret); rpc-test com RpcTest.makeClient + AtomRpc.Service({ makeEffect }); pg-test com PGlite/SQLite pelo mesmo SqlClient.

Um bom teste exercita um contrato pelo lado de fora: uma tag RPC, uma interface de serviço, um webhook assinado, uma tabela pelo SqlClient. Nunca afirma sobre wiring interno de Layer.

Observabilidade: OTLP server-side, traces e métricas, propagação de span ligada, sem export do browser. Sete métricas: webhook_received_total{event}, webhook_ack_duration_ms, webhook_signature_invalid_total, rpc_inflight, rpc_duration_ms{tag}, reconcile_lag_seconds, store_write_duration_ms.

Plano de construção: contrato congelado, depois paralelismo

Quem constrói são agentes executores, um por unidade, em paralelo. Para isso, todo ticket depende só de contratos congelados. Não existe roadmap temporal. A ordem abaixo é ordem de dependência.

FaseUnidadesConteúdo
Tier 0: contrato congelado12C-01 workspace e pins; C-02 ids e enums; C-03 erros; C-04 RpcGroup raiz; C-05 reactivity keys; C-06 server params e presets; C-07 interfaces Context.Service, zero impl; C-08 doubles; C-09 signWebhook e fixtures; C-10 faker e SDL pinado; C-11 TableRegistry e migrator; C-12 config. Tudo mergeado antes de qualquer dispatch.
Famílias de entidade73Para cada uma das 11 entidades: SCHEMA, MIGRATION, REPO, BOOTSTRAP, RECONCILE, RPC. Mais WEBHOOK para as 7 que têm webhook. WEBHOOK e RECONCILE começam contra mock/{Entity}Store; a aceitação pg-test usa o repositório real.
Ingest23I-01 a I-23: rota de webhook, fila, rate limiter, reconcile loops, OAuth, token store, write-through, shutdown em SIGTERM, alarmes, drift de schema.
UI16W-01 a W-16: scaffold, router e registry, lista virtualizada, detalhe, board por WorkflowState, filtros na URL, criação otimista, error boundary, resync.
Desktop6T-01, T-02 (security contract), T-03 (transporte WS nativo), T-05 (CI, signing, notarization), T-06, T-06b (updater).
Qualidade10Q-01 a Q-10: os sete jobs do gate, jobs fora do gate, regra de leitura dos clones pinados.
Spikes6S-03 a S-08. S-04 prova o cliente RPC in-process que todo ticket de UI usa e precisa estar verde antes de qualquer W-*.
Integração1Bloqueada por todas as outras. Boot do ingest contra PGlite, bootstrap contra faker, fixture assinada de Issue, linha no mirror, delta num RPC stream: true consumido pelo loader da lista, mesmo round-trip a partir de um tauri build. Sete jobs verdes. wire-schema.json inalterado.

Arestas sequenciais só onde um dado real flui: MIGRATION ← SCHEMA (lista de colunas), REPO ← SCHEMA (tipagem). A aresta REPO → WEBHOOK/RECONCILE foi removida por decisão do dono: o gate usa mock/{Entity}Store. Cross-track: I-07 ← I-06, W-05 ← W-16, W-09 ← I-20, W-14 ← W-03, T-06 ← T-05, Q-06 ← C-01.

Cada brief nomeia o arquivo de referência no clone da Effect que o executor deve ler. ai-docs cobre sql, http-server, testing e schedule, mas não cobre rpc, httpapi nem reactivity. Para esses, a referência são os testes e fixtures do repo. Sem isso, o agente inventa a API. Foi o que produziu 11 símbolos errados nos relatórios web anteriores.

Docs só dos clones locais em lib-docs-raw/, na tag ou commit pinado: effect @ 9642776, router @ ebf13ed, effect-atom @ 60bcae0, tauri @ tauri-v2.11.5.

Fora do escopo, por decisão

Produto

  • Mobile
  • Times privados
  • CustomView do Linear
  • IssueHistory espelhado
  • Réplica local no desktop
  • Escala horizontal

Técnica

  • Rust commands e sidecar
  • SSE e HttpApi
  • TanStack DB e TanStack Query
  • unstable/workflow
  • Matriz de CI multi-plataforma
  • OTLP do browser, eslint/prettier/dprint, SharedWorker, changesets
Estado das decisões: 58 perguntas respondidas pelo dono em 8 rodadas de grill. Nenhuma pendente. 38 gaps continuam abertos, nenhum bloqueia dispatch, cada um tem workaround escrito no ticket correspondente.

Versões pinadas

PacoteVersãoNota
effect, @effect/*4.0.0-rc.112Instalar por tag @rc. effect@latest é 3.22.1. catalog: exato, pnpm.overrides, assert de lockfile no CI.
@effect/atom-react4.0.0-rc.112@latest é 4.0.0-beta.107. @effect-atom/* é a linha v3, incompatível.
@tanstack/react-start1.168.49, pin exatoRC há ~11 meses, sem 1.0.0. API estável, semver frouxo.
react19.xInterseção dos peers do Start e do atom-react.
tauri / tauri-build / cli / api2.11.5 / 2.6.3 / 2.11.4 / 2.11.1Plugins: http 2.6.0, shell 2.3.6, updater 2.11.0, websocket 2.4.3. MSRV Rust 1.77.2.
@tanstack/react-virtual3.14.10
@tanstack/react-form1.33.5Com Schema.toStandardSchemaV1.
node / pnpm / rust (mise)24.8.0 / 11.21.0 / 1.77.2Ingest exige Node 22.16+.
oxlint / oxfmt1.81.0 / 0.66.0oxlint-plugin-effect@0.12.0, oxlint-tsgolint >=7.0.2001.
@linear/sdk92.0.0Raw GraphQL é linearClient.client.rawRequest(...).