# Composable Blueprint > **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository > **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js` > **Aplicável:** todo composable que orquestra estado reativo sobre uma repository --- ## 1. Princípio Composable é **wrapper fino** sobre a repository. Responsabilidade: - Manter **estado reativo** (data + loading + error) - Chamar a repository (delegação 1:1) - (Opcional) Cachear com stale-while-revalidate - (Opcional) Compor outros composables **Não faz:** - Lógica de banco direta (vai no repository) - Lógica de UI (vai no componente) - Manipulação de DOM - I/O direto fora do repository > Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.** --- ## 2. Estrutura de arquivos ``` src/features//composables/ ├── use.js # CRUD básico (thin wrapper) ├── useClinic.js # variant clinic-scoped (se aplicável) ├── useSettings.js # config/preferences (com cache opt-in) ├── useLifecycle.js # orquestrador de estados (se domain complexo) └── Helpers.js # funções puras auxiliares (não-composable) ``` **Convenção de nome:** sempre `use...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables. --- ## 3. State shape canônico Todo composable expõe **no mínimo** este shape: ```js const rows = ref([]); // ou single ref dependendo do domínio const loading = ref(false); // boolean const error = ref(''); // string vazia, não null — facilita v-if ``` **Decisões importantes:** | Refs | Tipo | Inicial | Por quê | |---|---|---|---| | `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto | | `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check | | `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível | **Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro. --- ## 4. Tipos de composable (3 patterns) ### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js` CRUD direto, sem cache, com loading/error em TODA operação: ```js import { ref } from 'vue'; import { listX, createX, updateX, deleteX } from '@/features//services/Repository'; export function useX() { const rows = ref([]); const loading = ref(false); const error = ref(''); async function loadRange({ startISO, endISO, ...scope } = {}) { loading.value = true; error.value = ''; try { rows.value = await listX({ startISO, endISO, ...scope }); } catch (e) { error.value = e?.message || 'Falha ao carregar.'; rows.value = []; } finally { loading.value = false; } } async function create(payload, opts = {}) { loading.value = true; error.value = ''; try { return await createX(payload, opts); } catch (e) { error.value = e?.message || 'Falha ao criar.'; throw e; // ← re-throw: composable repassa o erro pro componente decidir } finally { loading.value = false; } } async function update(id, patch, opts = {}) { /* idem */ } async function remove(id, opts = {}) { /* idem */ } return { rows, loading, error, loadRange, create, update, remove }; } ``` **Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra: - Mostrar toast - Não fechar modal - Não navegar - Manter form com dados `error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também. ### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js` Variant aceitável quando mutações **não precisam de loading**: ```js async function create(payload) { return createX(payload); // ← repassa erro nativamente; componente try/catch } ``` **Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A. ### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js` Para dados raros/pesados (settings, preferences, listas estáveis): ```js import { ref } from 'vue'; import { getX } from '../services/Repository'; import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore'; export function useX(opts = {}) { const useCache = !!opts.cache; const cache = useCache ? useMelissaCacheStore() : null; const data = ref(null); const loading = ref(false); const error = ref(''); async function _doFetch() { const result = await getX(); data.value = result; if (cache) { const key = result?.owner_id || 'anon'; cache.set('xKey', result, key); } return result; } async function load() { if (cache) { const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey); if (cached) { data.value = cached; _doFetch().catch((e) => console.warn('[useX] revalidate', e)); return; } } loading.value = true; error.value = ''; try { await _doFetch(); } catch (e) { error.value = e?.message || 'Falha ao carregar.'; data.value = null; } finally { loading.value = false; } } return { data, loading, error, load }; } ``` **Decisões do Tipo C:** - **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in. - **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant. - **TTL constants no store** — `MELISSA_CACHE_TTL.` (não hardcoded no composable). - **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await). - **Revalidate fail é warn**, não error — UI já tem dados válidos do cache. --- ## 5. Convenções de nomenclatura ### Funções | Operação | Nome canônico | Variantes aceitas | |---|---|---| | Listar com filtro | `loadRange` / `loadMy` | `load` | | Criar | `create` | `create` (se houver ambiguidade) | | Atualizar | `update` | `update` | | Remover | `remove` | `remove` (nunca `delete` — palavra reservada) | | Recarregar | `refresh` | `reload` | | Limpar estado | `reset` / `clear` | — | **Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin). ### State refs - `rows` — coleção principal (array) - `record` — single (quando faz sentido) - `data` — genérico (settings, config) - `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving` - `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar --- ## 6. Anatomia padrão de uma operação `load*` ```js async function loadXxx(args) { // 1. Validação leve (early return, não throw) if (!args?.required) return; // 2. State flag loading.value = true; error.value = ''; try { // 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all) const result = await listX(args); // 4. Mutate state rows.value = result; } catch (e) { // 5. Erro humano + reset de data (UI fica previsível) error.value = e?.message || 'Mensagem PT-BR genérica.'; rows.value = []; } finally { // 6. Sempre limpar loading loading.value = false; } } ``` **Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository. --- ## 7. Múltiplos fetches paralelos Quando uma operação precisa de N queries: ```js async function _doFetch() { const [cfg, rules, profile] = await Promise.all([ getMyAgendaSettings(), getMyWorkSchedule(), getMyProfile() ]); settings.value = cfg; workRules.value = rules; profile.value = profile; } ``` **Regras:** - `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira - Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result - **Nunca** sequenciar fetches independentes (await + await + await) --- ## 8. Composição de composables Composable pode usar outros composables, mas: ```js // ✅ certo — composição estrutural export function useAgendaEventLifecycle() { const events = useAgendaEvents(); const billing = useAgendaFinanceiro(); const settings = useAgendaSettings({ cache: true }); async function realizar(eventId) { // orquestra os 3 } return { ...events, realizar, ... }; } // ❌ errado — não compor pra economizar 1 linha export function useOnlyToWrapList() { const { rows, loadMyRange } = useAgendaEvents(); return { rows, loadMyRange }; // ← isso é um re-export inútil } ``` **Regra:** compõe quando há **orquestração**. Se é só forward, importa direto. --- ## 9. Anti-patterns (NÃO fazer) ### ❌ Composable que tem `supabase.from('...')` direto ```js // ❌ — violação de camadas export function useFoo() { async function load() { const { data } = await supabase.from('foo').select('*'); } } ``` ✅ Move pra repository, composable só delega. ### ❌ `error` ora `null`, ora `''`, ora `Error` Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`. ### ❌ Não resetar `rows` em erro de load ```js // ❌ async function loadRange() { try { rows.value = await listX(); } catch (e) { error.value = e.message; } // rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro } ``` ✅ Reset `rows.value = []` no catch — UI fica determinística. ### ❌ Não re-throw mutações ```js // ❌ async function create(payload) { try { return await createX(payload); } catch (e) { error.value = e.message; } // componente faz `await create()` e nunca sabe que falhou } ``` ✅ Re-throw após setar `error.value`. ### ❌ `Promise.all` quando uma falha é aceitável Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais. ### ❌ State global em variável módulo ```js // ❌ — vaza entre componentes que compartilham o composable const rows = ref([]); export function useFoo() { return { rows }; } ``` ✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store. ### ❌ Composable que faz `watch` no próprio state pra "side effect" ```js // ❌ const rows = ref([]); watch(rows, () => { /* save algo */ }); ``` ✅ Mover `watch` pro componente — composable não decide quando salvar. **Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK. ### ❌ Composable retornando objeto enorme Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store. --- ## 10. Cache store (Tipo C complementar) Quando criar um composable Tipo C, garantir que existe entry em: - `src/stores/melissaCacheStore.js` — `MELISSA_CACHE_TTL.` constante (TTL em ms) - `.get(key, scope, ttl)` retorna valor ou null - `.set(key, value, scope)` salva com timestamp - Invalidação manual: `.invalidate('')` **TTL guidelines:** | Tipo de dado | TTL sugerido | |---|---| | Settings/preferences | 5 min | | Listas estáveis (specialties, plans) | 30 min | | Catálogo (services, pricing) | 10 min | | Multi-tenant lookups | 5 min | | Anything user-edited | NÃO cachear (Tipo A) | --- ## 11. Checklist de auditoria por módulo Quando rodar `/audit-module `, validar cada composable: - [ ] Não tem `supabase.from(...)` direto — só importa da repository - [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string` - [ ] `error` é string, default `''` - [ ] Reset de data em erro de load (`rows.value = []`) - [ ] Mutações re-throw após setar error.value - [ ] Nomenclatura: `loadRange`/`load`, `create`, `update`, `remove` - [ ] `remove` não `delete` (palavra reservada) - [ ] Validação leve usa early-return (não throw) - [ ] Múltiplos fetches em `Promise.all` (não sequencial) - [ ] State DENTRO da `function use*()` (não em variável de módulo) - [ ] Sem `watch` em própria state pra side effect (mover pro componente) - [ ] Helpers de domínio em arquivo separado sem prefixo `use` - [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope - [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco) - [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais) Divergências viram items em `dev_auditoria_items` com: - `categoria`: `padronizacao` - `tag`: `padronizacao:` - `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função) --- ## 12. Exemplo completo (template) ```js /* | Arquivo: src/features/patients/composables/usePatients.js */ import { ref } from 'vue'; import { listPatients, createPatient, updatePatient, deletePatient } from '@/features/patients/services/patientsRepository'; export function usePatients() { const rows = ref([]); const loading = ref(false); const error = ref(''); async function loadRange({ search, status, tenantId } = {}) { loading.value = true; error.value = ''; try { rows.value = await listPatients({ search, status, tenantId }); } catch (e) { error.value = e?.message || 'Falha ao carregar pacientes.'; rows.value = []; } finally { loading.value = false; } } async function create(payload) { loading.value = true; error.value = ''; try { return await createPatient(payload); } catch (e) { error.value = e?.message || 'Falha ao criar paciente.'; throw e; } finally { loading.value = false; } } async function update(id, patch) { loading.value = true; error.value = ''; try { return await updatePatient(id, patch); } catch (e) { error.value = e?.message || 'Falha ao atualizar paciente.'; throw e; } finally { loading.value = false; } } async function remove(id) { loading.value = true; error.value = ''; try { await deletePatient(id); } catch (e) { error.value = e?.message || 'Falha ao remover paciente.'; throw e; } finally { loading.value = false; } } return { rows, loading, error, loadRange, create, update, remove }; } ``` --- ## 13. Referências - Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js` - Repository pareado: `blueprints/repository-blueprint.md` - Cache store: `src/stores/melissaCacheStore.js` - Tracker: `dev_auditoria_items` com tag `padronizacao:` - Estratégia: `development/02-auditoria/PADRONIZACAO.md`