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.
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,WorkflowStateeProjectMilestonesó mudam por polling. Colunas de board dependem deWorkflowState.
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.
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.
ON CONFLICT (id) DO UPDATE SET … WHERE mirror.updated_at < EXCLUDED.updated_at. Nunca combinar .returning() com ON CONFLICT no mesmo template.| Regra de schema | Por quê |
|---|---|
id é Model.GeneratedByApp | Os 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.String | Model.DateTimeUpdate sobrescreve com now a cada update e destrói o version guard. |
Soft delete em archivedAt, trashed, deletedAt | Nunca 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 String | Enums com escape hatch. Um valor novo no Linear não pode derrubar o decode. |
| Attachments: só metadata | URL assinada derivada na leitura via public-file-urls-expire-in. Nunca armazenada. |
IssueHistory lazy | Carregado ao abrir o detalhe, cache em coluna raw própria com TTL. Backfill completo queimaria o budget. |
Migrations com id YYYYMMDDHHmmss | O 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
Verificar HMAC-SHA256.
timingSafeEqualapós guard de tamanho, janela de ±60 s no timestamp. Assinatura inválida incrementawebhook_signature_invalid_total. - 2
Persistir a entrega cru. Tabela de deliveries com PK
delivery_ide índice único em(entity_type, entity_id, action, webhook_timestamp). - 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
Aplicar via
PersistedQueue. Chave de idempotência derivada do conteúdo:type:data.id:data.updatedAt. Semunstable/workflow: é uma camada sobrePersistedQueuee só é durável com cluster. - 5
Decodificar e normalizar. Família de schemas
*WebhookPayloadseparada da GraphQL (32 diferenças de tipo). Decode pelo headerLinear-Eventprimeiro. Router de mundo aberto com quarentena. Falha de decode responde 500 (retry), nunca 400. - 6
Upsert com version guard e publicar reactivity keys.
Object.keys(updatedFrom)é o conjunto de colunas alteradas. Valores novos sempre vêm dedata;updatedFromtraz 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.
["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.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).AtomRegistryvive emrouter.context.loaderDepsvem 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.optimistice 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 emWrap.tanstackStart()antes deviteReact().getRouteré nome load-bearing.
Proibido, com lint
fetch,RpcClientouAtomRpc.queryfora de loaders e atoms. Regra de lint bloqueia.- TanStack Query.
Atom.swr+Reactivityjá dão cache e invalidação. - TanStack DB. Beta, sem devtools, bug de startup em Tauri.
Atom.searchParam. Escreve empushStatee briga com o Router.- Server functions do Start como costura de dados. URL relativa e CSRF same-origin quebram sob Tauri.
exportde componente em arquivo de rota. Arquivos separados usamgetRouteApi('/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.
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.
| Config | Lado | Variáveis |
|---|---|---|
| Segredos | servidor | LINEAR_OAUTH_CLIENT_ID, LINEAR_OAUTH_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET (+ secundário na rotação), DATABASE_URL, OTLP_ENDPOINT, OTLP_HEADERS |
| Cliente | bundle | VITE_RPC_URL (absoluta), opcional VITE_OTLP_ENDPOINT |
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.
| Job | O que prova |
|---|---|
lint | oxlint + oxfmt. oxlint-plugin-effect@0.12.0 pinado, outdatedApi do tsgo, oxlint-tsgolint para regras type-aware. Sem eslint, prettier ou dprint. |
types | TS 7 (@typescript/native) + @effect/tsgo. TS 6 ao lado como escape hatch. |
types-contract | tstyche 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-freshness | wire-schema.json regenerado bate com o commitado. |
circular | Sem import circular entre pacotes. |
dedupe-effect | Uma ú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.
| Fase | Unidades | Conteúdo |
|---|---|---|
| Tier 0: contrato congelado | 12 | C-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 entidade | 73 | Para 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. |
| Ingest | 23 | I-01 a I-23: rota de webhook, fila, rate limiter, reconcile loops, OAuth, token store, write-through, shutdown em SIGTERM, alarmes, drift de schema. |
| UI | 16 | W-01 a W-16: scaffold, router e registry, lista virtualizada, detalhe, board por WorkflowState, filtros na URL, criação otimista, error boundary, resync. |
| Desktop | 6 | T-01, T-02 (security contract), T-03 (transporte WS nativo), T-05 (CI, signing, notarization), T-06, T-06b (updater). |
| Qualidade | 10 | Q-01 a Q-10: os sete jobs do gate, jobs fora do gate, regra de leitura dos clones pinados. |
| Spikes | 6 | S-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ção | 1 | Bloqueada 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
CustomViewdo LinearIssueHistoryespelhado- 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
Versões pinadas
| Pacote | Versão | Nota |
|---|---|---|
effect, @effect/* | 4.0.0-rc.112 | Instalar por tag @rc. effect@latest é 3.22.1. catalog: exato, pnpm.overrides, assert de lockfile no CI. |
@effect/atom-react | 4.0.0-rc.112 | @latest é 4.0.0-beta.107. @effect-atom/* é a linha v3, incompatível. |
@tanstack/react-start | 1.168.49, pin exato | RC há ~11 meses, sem 1.0.0. API estável, semver frouxo. |
react | 19.x | Interseção dos peers do Start e do atom-react. |
tauri / tauri-build / cli / api | 2.11.5 / 2.6.3 / 2.11.4 / 2.11.1 | Plugins: http 2.6.0, shell 2.3.6, updater 2.11.0, websocket 2.4.3. MSRV Rust 1.77.2. |
@tanstack/react-virtual | 3.14.10 | |
@tanstack/react-form | 1.33.5 | Com Schema.toStandardSchemaV1. |
| node / pnpm / rust (mise) | 24.8.0 / 11.21.0 / 1.77.2 | Ingest exige Node 22.16+. |
oxlint / oxfmt | 1.81.0 / 0.66.0 | oxlint-plugin-effect@0.12.0, oxlint-tsgolint >=7.0.2001. |
@linear/sdk | 92.0.0 | Raw GraphQL é linearClient.client.rawRequest(...). |