SQLite FTS5 e Cloudflare D1
Full-Text Search no Cloudflare D1
Como usar FTS5 no D1 (extensão suportada), content tables, triggers de sync e cuidados em Workers.
Nesta aula você vai
- Criar índices FTS5 no D1 com content table e content_rowid
- Manter o índice sincronizado com triggers AFTER INSERT/UPDATE/DELETE
- Consultar via Workers respeitando limites e boas práticas do D1
Full-Text Search no Cloudflare D1
Objetivos
Nesta aula você vai:
- Criar índices FTS5 no D1 com
contenttable econtent_rowid - Manter o índice sincronizado com triggers
AFTER INSERT/UPDATE/DELETE - Consultar via Workers respeitando limites e boas práticas do D1
Introdução
Cloudflare D1 é SQLite gerenciado na edge. A extensão fts5 (sempre em minúsculas na declaração) está disponível, o que permite full-text search sem um serviço externo — ideal para catálogos, docs e busca de conteúdo em apps Workers.
O padrão recomendado em produção é: tabela relacional canônica + virtual table FTS com content='...' + triggers de sincronização.
Conteúdo
Declaração no D1
Em migrações D1 (wrangler d1 migrations):
-- migrations/0001_fts.sql
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title,
body,
content='articles',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
Atenção: use fts5 em minúsculo. content aponta para a tabela real; content_rowid deve ser a PK inteira usada como rowid do índice.
Triggers de sincronização
Com external content, inserts na tabela articles não atualizam o índice sozinhos. O padrão FTS5:
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.body);
INSERT INTO articles_fts(rowid, title, body)
VALUES (new.id, new.title, new.body);
END;
A forma 'delete' no primeiro argumento é o comando especial do FTS5 para remover do índice com external content.
Backfill
Para dados já existentes:
INSERT INTO articles_fts(rowid, title, body)
SELECT id, title, body FROM articles;
Rode uma vez após criar a virtual table (e antes ou depois dos triggers, conforme o estado do índice — evite duplicar se triggers já popularem novos inserts).
Consulta no Worker
export default {
async fetch(request, env) {
const url = new URL(request.url);
const q = buildFtsQuery(url.searchParams.get('q'));
if (!q) {
return Response.json({ results: [] });
}
const { results } = await env.DB.prepare(
`SELECT a.id, a.title,
snippet(articles_fts, 1, '<mark>', '</mark>', '…', 32) AS snippet,
bm25(articles_fts, 10.0, 1.0) AS score
FROM articles a
JOIN articles_fts ON articles_fts.rowid = a.id
WHERE articles_fts MATCH ?1
AND a.status = 'published'
ORDER BY score
LIMIT 20`
)
.bind(q)
.all();
return Response.json({ results });
},
};
Use prepare + bind — não interpolar a query FTS com concatenação insegura. Ainda assim, valide/sanitize tokens porque a sintaxe FTS é uma linguagem própria.
Cuidados específicos do D1 / Workers
- Tamanho e limites: D1 tem limites de tamanho de DB, linhas retornadas e tempo de CPU do Worker. Paginação curta (
LIMIT/OFFSETou keyset). - Latência de escrita: triggers adicionam custo a cada INSERT/UPDATE/DELETE — aceitável para CMS; pesado em ingestão massiva (prefira batch + rebuild controlado).
- Rebuild:
INSERT INTO articles_fts(articles_fts) VALUES('rebuild');quando o índice corromper ou após mudança de tokenizer (planeje downtime/janela). - Transações: mantenha escrita na tabela + consistência dos triggers na mesma transação quando usar batch API.
- Não confunda com Vectorize: FTS5 é lexical; embeddings vão para Vectorize ou similar.
Exemplos práticos
# wrangler.toml (trecho)
[[d1_databases]]
binding = "DB"
database_name = "app-db"
database_id = "<id>"
-- Busca com filtro relacional
SELECT a.id, a.title, bm25(articles_fts) AS score
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH 'd1 AND fts5'
AND a.status = 'published'
ORDER BY score
LIMIT 10;
function buildFtsQuery(raw) {
if (!raw) return null;
const parts = 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, 10);
if (!parts.length) return null;
return parts.map((t) => `"${t}"`).join(' AND ');
}
Problemas e como resolver
| Problema | Causa | Mitigação |
|---|---|---|
| Índice desatualizado | Triggers ausentes ou falhos | Recriar triggers; backfill; rebuild |
| Erro ao criar FTS | Nome FTS5 / sintaxe |
Usar USING fts5(...) |
| Match sem linhas publicadas | Só filtro FTS | Join + status |
| Timeout no Worker | Query ampla / OFFSET grande | Query restrita, LIMIT baixo, keyset |
Resumo
No D1, modele content + content_rowid, sincronize com triggers INSERT/UPDATE/DELETE, faça backfill e consulte com MATCH + bm25/snippet a partir do Worker. Trate a string FTS como linguagem a sanitizar e respeite limites da edge. Próxima aula: padrões de autocomplete, paginação e quando sair do D1.