Convenções de Schema¶
Como o banco de dados do Spryx é estruturado, como nomear coisas, e os padrões que se repetem em todas as tabelas.
Organização dos arquivos de schema¶
Os schemas ficam em supabase/schemas/ e são a fonte de verdade — não as migrations. Nunca edite migrations manualmente para alterar o schema; edite os arquivos de schema e gere a migration com make db-diff.
supabase/schemas/
├── 00_global/
│ ├── 01_extensions.sql # pgvector, pg_graphql, etc.
│ ├── 02_domains.sql # Typed ID domains
│ ├── 03_enums.sql # Todos os enums do projeto
│ └── 04_grants.sql # Permissões por role
├── 01_tables.sql # Todas as tabelas (ordenadas por FK)
├── 02_indexes.sql # Todos os índices
└── 03_functions.sql # Funções e triggers
Os arquivos 00_global/ são aplicados antes das tabelas — isso é necessário porque tabelas referenciam domínios e enums que precisam existir primeiro.
Schema único: public¶
Tudo fica no schema public. O projeto não usa schemas por módulo no banco — o isolamento de módulos é feito na camada de aplicação (repositórios, DI), não no banco.
RLS desabilitado — acesso controlado exclusivamente pela camada de aplicação via service_role. O anon e authenticated não têm acesso a tabelas.
Sistema de typed IDs (domínios)¶
Cada entidade tem um domínio PostgreSQL que valida o formato prefixo_ULID26:
-- supabase/schemas/00_global/02_domains.sql
CREATE DOMAIN "tenant_id" AS "text"
CHECK (VALUE ~ '^tenant_[0-9A-Z]{26}$');
CREATE DOMAIN "agent_id" AS "text"
CHECK (VALUE ~ '^agent_[0-9A-Z]{26}$');
Na aplicação Python, cada entidade tem um PrefixedULID correspondente:
O domínio no banco garante que um agent_id nunca seja inserido em uma coluna tenant_id — validação em dois níveis: tipo Python + constraint de banco.
Para criar um novo tipo de ID: adicionar o domínio em 02_domains.sql e criar o PrefixedULID correspondente.
Enums¶
Todos os enums do projeto ficam em supabase/schemas/00_global/03_enums.sql, organizados por módulo:
-- Convenção: valores em snake_case lowercase
CREATE TYPE "subscription_status" AS ENUM (
'active',
'trialing',
'past_due',
'canceled',
'suspended'
);
Regras:
- Nomes de tipo em snake_case
- Valores em lowercase (mapeados para StrEnum no Python)
- Um enum por conceito de negócio — não reutilizar enums genéricos
Limitação: ALTER TYPE ... ADD VALUE não pode ocorrer dentro de uma transação. Para adicionar valores a um enum existente, a migration precisa rodar fora de bloco de transação ou usar COMMIT explícito.
Padrões de tabela¶
Colunas obrigatórias¶
Toda tabela de entidade de negócio segue este padrão:
CREATE TABLE "agents" (
"id" agent_id PRIMARY KEY NOT NULL,
"tenant_id" tenant_id NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
-- ... colunas específicas ...
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL
);
| Coluna | Tipo | Regra |
|---|---|---|
id |
domínio tipado | PK, gerado pela aplicação (não SERIAL) |
tenant_id |
tenant_id |
FK obrigatória para tenants(id) em tabelas tenant-scoped |
created_at |
timestamptz |
Sempre com timezone |
updated_at |
timestamptz |
Atualizado na aplicação ou por trigger |
Soft delete¶
Tabelas com exclusão lógica usam deleted_at:
Não há coluna is_deleted booleana. A query de leitura sempre filtra WHERE deleted_at IS NULL.
Nomes de tabela¶
snake_case, plural:agents,tenant_roles,workflow_versions- Tabelas de junção:
{tabela_a}_{tabela_b}— ex:tenant_role_permissions - Tabelas de eventos/histórico:
{entidade}_events,{entidade}_history
JSONB vs colunas flat¶
Use colunas flat quando o campo é consultável¶
Campos usados em WHERE, JOIN, ou índices devem ser colunas separadas:
-- ✅ Correto — actor_type é filtrado em queries de auditoria
"actor_type" event_actor_type NOT NULL,
"actor_user_id" user_id,
"actor_staff_id" staff_id,
Use JSONB para estrutura variável não consultável¶
Credenciais, configurações de OAuth, dados de payload de webhook:
Colunas geradas para extrair de JSONB¶
Quando um campo JSONB precisa ser indexado ou filtrado:
"status" workflow_status GENERATED ALWAYS AS (
CASE
WHEN archived_at IS NOT NULL THEN 'archived'
WHEN published_at IS NOT NULL THEN 'published'
ELSE 'draft'
END
) STORED;
CHECK constraints¶
Use CHECK constraints para garantir consistência em discriminated unions:
-- Garante que cada actor_type tem os campos corretos preenchidos
CONSTRAINT "chk_actor_fields" CHECK (
(actor_type = 'user' AND actor_user_id IS NOT NULL AND actor_staff_id IS NULL) OR
(actor_type = 'staff' AND actor_staff_id IS NOT NULL AND actor_user_id IS NULL) OR
(actor_type = 'system' AND actor_user_id IS NULL AND actor_staff_id IS NULL) OR
(actor_type = 'impersonated' AND actor_user_id IS NOT NULL AND actor_staff_id IS NOT NULL)
)
Índices¶
Todos os índices ficam em supabase/schemas/02_indexes.sql. Convenções de nome:
-- Partial index: inclui apenas as linhas relevantes
CREATE INDEX "ix_sessions_refresh_token_hash"
ON "sessions" USING btree ("refresh_token_hash")
WHERE deleted_at IS NULL;
-- Composite index para queries tenant-scoped
CREATE INDEX "ix_agents_tenant_id_status"
ON "agents" USING btree ("tenant_id", "status");
Regras:
- Sempre crie índice em colunas de FK tenant-scoped (tenant_id) para evitar seq scan
- Use partial indexes quando a condição é seletiva (WHERE status = 'active')
- Índices vetoriais (ivfflat, hnsw) ficam junto com os demais em 02_indexes.sql
Grants e acesso¶
Apenas service_role tem acesso a tabelas. A aplicação conecta exclusivamente via service_role:
-- supabase/schemas/00_global/04_grants.sql
REVOKE ALL ON ALL TABLES IN SCHEMA "public" FROM anon, authenticated;
GRANT ALL ON ALL TABLES IN SCHEMA "public" TO "service_role";
Nunca habilite RLS para controle de acesso — o isolamento de tenant é responsabilidade da camada de repositório.