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.
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 /searchcomMATCHparametrizado 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:
MATCH ?1+.bind(ftsQuery)— nunca concateneqcru na string SQL.- Sanitização — a linguagem FTS ainda pode interpretar operadores; tokenizar e quotar é a defesa prática.
- Limites — comprimento de
q, número de tokens eLIMITprotegem CPU do Worker e o D1. - 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.