Pular para conteúdo

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:

class TenantID(PrefixedULID):
    prefix: ClassVar[str] = "tenant_"

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:

"deleted_at" timestamptz   -- NULL = ativo, NOT NULL = excluído

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:

"oauth_data"   jsonb,
"plain_fields" jsonb    -- campos específicos por tipo de connection

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:

ix_{tabela}_{coluna(s)}
-- 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.