Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent Dialog.vue.bak deletado (lixo de refator anterior). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
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.jsAplicá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/<modulo>/composables/
├── use<Entity>.js # CRUD básico (thin wrapper)
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
Convenção de nome: sempre use<Entity>.... 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:
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:
import { ref } from 'vue';
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>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:
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):
import { ref } from 'vue';
import { getX } from '../services/<feature>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.cachedefaultfalse— 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.<feature>(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<X> |
load<Scope><Range> |
| Criar | create |
create<Scope> (se houver ambiguidade) |
| Atualizar | update |
update<Scope> |
| Remover | remove |
remove<Scope> (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últiplosloading(load vs save), nomear:loadingList,savingerror— string única; mesmo princípio:loadError,saveErrorse precisar
6. Anatomia padrão de uma operação load*
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:
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ãoPromise.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:
// ✅ 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
// ❌ — 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
// ❌
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
// ❌
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
// ❌ — 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"
// ❌
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.<feature>constante (TTL em ms).get(key, scope, ttl)retorna valor ou null.set(key, value, scope)salva com timestamp- Invalidação manual:
.invalidate('<feature>')
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 <nome>, 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<Scope>,create,update,remove removenãodelete(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
watchem própria state pra side effect (mover pro componente) - Helpers de domínio em arquivo separado sem prefixo
use - Se cacheia (Tipo C):
opts.cacheopt-in, defaultfalse; TTL emMELISSA_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:padronizacaotag:padronizacao:<modulo>severidade: alta se camada quebrada (composable comfrom()); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
12. Exemplo completo (template)
/*
| 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_itemscom tagpadronizacao:<modulo> - Estratégia:
development/02-auditoria/PADRONIZACAO.md