Laboratório: busca de e-commerce com SQLite, D1 e Workers

Endpoint de busca em Cloudflare D1 e Workers

Migração do schema para D1, binding no wrangler e rota GET /search no Worker.

Intermediário 55 min 36 pontos Leitura 0%

Nesta aula você vai

  • Configurar binding D1 e aplicar migrações FTS5
  • Implementar GET /search com MATCH parametrizado e JSON
  • Validar entrada, tratar erros e rodar wrangler d1 / wrangler dev

Endpoint de busca em Cloudflare D1 e Workers

Objetivos

Nesta aula você vai:

  • Configurar binding D1 e aplicar migrações FTS5
  • Implementar GET /search com MATCH parametrizado e JSON
  • Validar entrada, tratar erros e rodar wrangler d1 / wrangler dev

Introdução

O padrão local (tabela + FTS5 + triggers) é o mesmo no Cloudflare D1. A diferença está no empacotamento: migrações versionadas, [[d1_databases]] no Wrangler e um Worker que consulta com env.DB.prepare(...).bind(...).all().

Não invente SDKs paralelos: a API estável do Worker é o binding D1 exposto em env.

Conteúdo

Binding no wrangler.toml

name = "busca-ecommerce-lab"
main = "src/index.ts"
compatibility_date = "2024-11-01"

[[d1_databases]]
binding = "DB"
database_name = "busca-ecommerce-lab"
database_id = "<SEU_DATABASE_ID>"
migrations_dir = "migrations"

Crie o banco (uma vez):

npx wrangler d1 create busca-ecommerce-lab

Copie o database_id gerado para o wrangler.toml.

Migração SQL (fts5 em minúsculas)

migrations/0001_products_fts.sql:

CREATE TABLE IF NOT EXISTS products (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  category TEXT NOT NULL,
  tags TEXT NOT NULL DEFAULT '',
  price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
);

CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);

CREATE VIRTUAL TABLE IF NOT EXISTS products_fts USING fts5(
  name,
  description,
  category,
  tags,
  content='products',
  content_rowid='id',
  tokenize='unicode61 remove_diacritics 2'
);

CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN
  INSERT INTO products_fts(rowid, name, description, category, tags)
  VALUES (new.id, new.name, new.description, new.category, new.tags);
END;

CREATE TRIGGER products_ad AFTER DELETE ON products BEGIN
  INSERT INTO products_fts(products_fts, rowid, name, description, category, tags)
  VALUES ('delete', old.id, old.name, old.description, old.category, old.tags);
END;

CREATE TRIGGER products_au AFTER UPDATE ON products BEGIN
  INSERT INTO products_fts(products_fts, rowid, name, description, category, tags)
  VALUES ('delete', old.id, old.name, old.description, old.category, old.tags);
  INSERT INTO products_fts(rowid, name, description, category, tags)
  VALUES (new.id, new.name, new.description, new.category, new.tags);
END;

Seed (segunda migração ou arquivo à parte) com os mesmos INSERT da aula 1. Se o seed for aplicado depois dos triggers, o índice preenche sozinho; caso contrário, faça backfill.

Aplicar:

npx wrangler d1 migrations apply busca-ecommerce-lab --local
npx wrangler d1 migrations apply busca-ecommerce-lab --remote

Execução ad hoc:

npx wrangler d1 execute busca-ecommerce-lab --local --command "SELECT COUNT(*) FROM products"
npx wrangler d1 execute busca-ecommerce-lab --local --file=./seed.sql

Worker: GET /search?q=

src/index.ts (TypeScript; o equivalente JS é idêntico sem tipos):

export interface Env {
  DB: D1Database;
}

const MAX_Q_LEN = 80;
const MAX_RESULTS = 20;

function sanitizeToFtsQuery(raw: string | null): string | null {
  if (!raw) return null;
  if (raw.length > MAX_Q_LEN) return null;

  const tokens = raw
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
    .trim()
    .split(/\s+/)
    .filter((t) => t.length >= 2)
    .slice(0, 8);

  if (!tokens.length) return null;

  // Tokens entre aspas + AND: reduz surpresas da sintaxe FTS
  return tokens.map((t) => `"${t}"`).join(' AND ');
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (request.method !== 'GET') {
      return Response.json({ error: 'method_not_allowed' }, { status: 405 });
    }

    if (url.pathname !== '/search') {
      return Response.json({ error: 'not_found' }, { status: 404 });
    }

    const qRaw = url.searchParams.get('q');
    if (qRaw !== null && qRaw.length > MAX_Q_LEN) {
      return Response.json(
        { error: 'query_too_long', maxLength: MAX_Q_LEN },
        { status: 400 }
      );
    }

    const ftsQuery = sanitizeToFtsQuery(qRaw);
    if (!ftsQuery) {
      return Response.json({ query: qRaw ?? '', results: [] });
    }

    try {
      const { results } = await env.DB.prepare(
        `SELECT
           p.id,
           p.name,
           p.category,
           p.price_cents,
           snippet(products_fts, 1, '<mark>', '</mark>', '…', 24) AS snippet,
           bm25(products_fts, 10.0, 1.0, 2.0, 3.0) AS score
         FROM products_fts
         JOIN products p ON p.id = products_fts.rowid
         WHERE products_fts MATCH ?1
         ORDER BY score
         LIMIT ?2`
      )
        .bind(ftsQuery, MAX_RESULTS)
        .all();

      return Response.json({
        query: qRaw,
        ftsQuery,
        results,
      });
    } catch (err) {
      console.error('search_failed', err);
      return Response.json({ error: 'search_failed' }, { status: 500 });
    }
  },
};

Pontos de atenção:

  1. MATCH ?1 + .bind(ftsQuery) — nunca concatene q cru na string SQL.
  2. Sanitização — a linguagem FTS ainda pode interpretar operadores; tokenizar e quotar é a defesa prática.
  3. Limites — comprimento de q, número de tokens e LIMIT protegem CPU do Worker e o D1.
  4. Erros — falha de sintaxe FTS residual ou D1 indisponível → 500 genérico + log; cliente não precisa do stack.

Dev local

npx wrangler dev

Em outro terminal:

curl -s 'http://127.0.0.1:8787/search?q=furadeira' | jq
curl -s 'http://127.0.0.1:8787/search?q=chave%20fenda' | jq
curl -s 'http://127.0.0.1:8787/search?q=' | jq

O Wrangler sobe o Worker com o D1 local (quando configurado), reutilizando migrações aplicadas com --local.

Exemplos práticos

Filtro relacional opcional (category):

const category = url.searchParams.get('category');
// Se category for whitelist (ferramentas|livros|gadgets), acrescente:
// AND p.category = ?3  e .bind(ftsQuery, MAX_RESULTS, category)

Resposta JSON esperada (formato):

{
  "query": "hub usb",
  "ftsQuery": "\"hub\" AND \"usb\"",
  "results": [
    {
      "id": 18,
      "name": "Hub USB-C MaréDock 7 em 1",
      "category": "gadgets",
      "price_cents": 24990,
      "snippet": "…com HDMI 4K…",
      "score": -5.12
    }
  ]
}

Problemas e como resolver

Problema Causa Mitigação
D1_ERROR / tabela inexistente Migração não aplicada wrangler d1 migrations apply ... --local
Binding undefined Nome diferente no toml binding = "DB" = env.DB
500 em toda busca Sintaxe FTS / aspas Revisar sanitizeToFtsQuery
Resultados vazios Seed só no remote ou só local Aplicar seed no mesmo alvo do dev
Criação FTS falha USING FTS5 USING fts5(...) minúsculo

Resumo

No D1, versiona o schema FTS5 em migrações, declara [[d1_databases]], aplica com wrangler d1 e expõe GET /search via env.DB.prepare + bind. Validação de entrada e sanitização da consulta FTS fazem parte do contrato da API, não um detalhe opcional. Na próxima aula, você compara LIKE vs FTS5 e monta a lista de verificação de evolução do laboratório.