Banco de dados sem npm install: o Node.js agora tem suporte nativo ao SQLite

Fala Dev, tudo em riba?

Pensa rápido: quantas vezes você precisou de um banco de dados simples para um projeto pequeno, um protótipo ou testes automatizados?

Provavelmente muitas. E provavelmente você instalou alguma dependência externa para isso. Better-sqlite3, sql.js, ou até subiu um container Docker com PostgreSQL só para guardar meia dúzia de registros.

A partir da versão 22.5, isso mudou. O Node.js agora traz o SQLite de forma nativa, integrado ao runtime. Zero instalação, zero configuração, zero dependências externas.

Isso abre possibilidades interessantes para prototipagem rápida, aplicações embarcadas, testes de integração e muito mais.

Vamos explorar como funciona e quando faz sentido usar.

O que é SQLite e por que isso importa

Se você não está familiarizado, SQLite é um banco de dados relacional que funciona de forma diferente dos bancos tradicionais como PostgreSQL ou MySQL.

Enquanto esses bancos rodam como serviços separados, o SQLite é um banco embarcado. Ele guarda todos os dados em um único arquivo no disco. Não precisa de servidor, não precisa de configuração de rede, não precisa de usuário e senha.

Isso parece limitado, mas na verdade é extremamente poderoso para vários cenários.

  • Aplicações desktop e mobile: Praticamente todo app de celular usa SQLite por baixo dos panos. É leve, rápido e não depende de conexão com internet.
  • Prototipagem: Quando você quer testar uma ideia rapidamente, subir um banco de dados completo é overhead desnecessário. SQLite resolve em segundos.
  • Testes automatizados: Criar um banco limpo para cada suite de testes fica trivial. É só criar um arquivo novo ou usar banco em memória.
  • Aplicações embarcadas: IoT, dispositivos com recursos limitados, aplicações que precisam funcionar offline.
  • Cache persistente: Guardar dados que precisam sobreviver a reinicializações, mas não justificam um banco externo.

O SQLite não substitui bancos de dados robustos para aplicações de alta concorrência. Mas para uma quantidade enorme de casos de uso, ele é mais que suficiente.

Como era antes: usando better-sqlite3

Até agora, a forma mais comum de usar SQLite no Node.js era através de bibliotecas como better-sqlite3. Vamos ver como ficava:

Primeiro, a instalação:

npm install better-sqlite3

Depois, o código:

import Database from 'better-sqlite3';

// Cria ou abre o banco de dados
const db = new Database('meu-banco.db');

// Cria uma tabela
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nome TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    criado_em DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

// Insere um registro
const inserir = db.prepare('INSERT INTO users (nome, email) VALUES (?, ?)');
inserir.run('João Silva', 'joao@example.com');

// Consulta registros
const buscarTodos = db.prepare('SELECT * FROM users');
const usuarios = buscarTodos.all();

console.log('Usuários:', usuarios);

// Fecha a conexão
db.close();

Funciona bem. A API é limpa e performática. Mas exige uma dependência externa com binários nativos, o que às vezes causa problemas em diferentes sistemas operacionais ou versões do Node.js.

Como fica agora: SQLite nativo

Com o SQLite nativo do Node.js, o código fica muito similar, mas sem precisar instalar nada:

import { DatabaseSync } from 'node:sqlite';

// Cria ou abre o banco de dados
const db = new DatabaseSync('meu-banco-nativo.db');

// Cria uma tabela
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nome TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL,
    criado_em DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

// Prepara e executa uma inserção
const inserir = db.prepare('INSERT INTO users (nome, email) VALUES (?, ?)');
inserir.run('João Silva', 'joao@example.com');

// Consulta registros
const buscarTodos = db.prepare('SELECT * FROM users');
const usuarios = buscarTodos.all();

console.log('Usuários:', usuarios);

// Fecha a conexão
db.close();

Perceba como a estrutura é praticamente idêntica. A diferença principal é o import: em vez de uma dependência externa, importamos de node:sqlite.

Para executar, basta rodar:

node app.js

Nada de npm install. O SQLite já está ali, pronto para usar.

Banco de dados em memória

Uma funcionalidade especialmente útil para testes é criar bancos em memória. Os dados existem apenas enquanto o processo está rodando e desaparecem quando ele termina.

import { DatabaseSync } from 'node:sqlite';

// Cria banco em memória (não persiste no disco)
const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE produtos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nome TEXT NOT NULL,
    preco REAL NOT NULL
  )
`);

const inserir = db.prepare('INSERT INTO produtos (nome, preco) VALUES (?, ?)');
inserir.run('Camiseta', 49.90);
inserir.run('Calça', 129.90);
inserir.run('Tênis', 299.90);

const produtos = db.prepare('SELECT * FROM produtos').all();
console.log('Produtos:', produtos);

// Quando o processo termina, os dados somem

Isso é perfeito para testes unitários e de integração. Cada teste pode ter seu próprio banco isolado, sem risco de interferência entre eles.

Consultas parametrizadas

Nunca concatene valores diretamente em queries SQL. Isso abre brechas para SQL injection. Sempre use consultas parametrizadas:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nome TEXT NOT NULL,
    idade INTEGER
  )
`);

// Inserção parametrizada
const inserir = db.prepare('INSERT INTO users (nome, idade) VALUES (?, ?)');
inserir.run('Maria', 28);
inserir.run('Pedro', 35);
inserir.run('Ana', 22);

// Consulta parametrizada
const buscarPorIdade = db.prepare('SELECT * FROM users WHERE idade > ?');
const maioresDe25 = buscarPorIdade.all(25);

console.log('Usuários com mais de 25 anos:', maioresDe25);

// Busca por nome
const buscarPorNome = db.prepare('SELECT * FROM users WHERE nome = ?');
const pedro = buscarPorNome.get('Pedro');

console.log('Pedro:', pedro);

Transações

Para operações que precisam ser atômicas, você pode usar transações:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE contas (
    id INTEGER PRIMARY KEY,
    titular TEXT NOT NULL,
    saldo REAL NOT NULL
  )
`);

// Cria duas contas
const inserir = db.prepare('INSERT INTO contas (id, titular, saldo) VALUES (?, ?, ?)');
inserir.run(1, 'Alice', 1000.00);
inserir.run(2, 'Bob', 500.00);

// Transferência entre contas usando transação
function transferir(deId, paraId, valor) {
  const debitar = db.prepare('UPDATE contas SET saldo = saldo - ? WHERE id = ?');
  const creditar = db.prepare('UPDATE contas SET saldo = saldo + ? WHERE id = ?');
  
  db.exec('BEGIN TRANSACTION');
  
  try {
    debitar.run(valor, deId);
    creditar.run(valor, paraId);
    db.exec('COMMIT');
    console.log(`Transferência de R$ ${valor} realizada com sucesso`);
  } catch (erro) {
    db.exec('ROLLBACK');
    console.error('Erro na transferência:', erro.message);
  }
}

// Executa transferência
transferir(1, 2, 200.00);

// Verifica saldos
const contas = db.prepare('SELECT * FROM contas').all();
console.log('Saldos atuais:', contas);

Se qualquer parte da transação falhar, o rollback garante que nenhuma alteração seja aplicada. Isso mantém a integridade dos dados.

Exemplo prático: sistema de cache

Vamos ver um exemplo mais realista. Um sistema de cache que persiste dados no disco:

import { DatabaseSync } from 'node:sqlite';

class Cache {
  constructor(arquivo = 'cache.db') {
    this.db = new DatabaseSync(arquivo);
    this.inicializar();
  }

  inicializar() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS cache (
        chave TEXT PRIMARY KEY,
        valor TEXT NOT NULL,
        expira_em INTEGER
      )
    `);
    
    // Remove entradas expiradas na inicialização
    this.limparExpirados();
  }

  set(chave, valor, ttlSegundos = null) {
    const expiraEm = ttlSegundos 
      ? Date.now() + (ttlSegundos * 1000) 
      : null;
    
    const stmt = this.db.prepare(`
      INSERT OR REPLACE INTO cache (chave, valor, expira_em) 
      VALUES (?, ?, ?)
    `);
    
    stmt.run(chave, JSON.stringify(valor), expiraEm);
  }

  get(chave) {
    const stmt = this.db.prepare(`
      SELECT valor, expira_em FROM cache WHERE chave = ?
    `);
    
    const registro = stmt.get(chave);
    
    if (!registro) return null;
    
    // Verifica se expirou
    if (registro.expira_em && Date.now() > registro.expira_em) {
      this.delete(chave);
      return null;
    }
    
    return JSON.parse(registro.valor);
  }

  delete(chave) {
    const stmt = this.db.prepare('DELETE FROM cache WHERE chave = ?');
    stmt.run(chave);
  }

  limparExpirados() {
    const stmt = this.db.prepare(`
      DELETE FROM cache WHERE expira_em IS NOT NULL AND expira_em < ?
    `);
    stmt.run(Date.now());
  }

  fechar() {
    this.db.close();
  }
}

// Uso
const cache = new Cache();

// Guarda dados sem expiração
cache.set('config', { tema: ‘escuro’, idioma: ‘pt-BR’ });

// Guarda dados com TTL de 60 segundos
cache.set('sessao:123', { usuario: ‘pedro’, logado: true }, 60);

// Recupera dados
console.log('Config:', cache.get('config'));
console.log('Sessão:', cache.get('sessao:123'));

cache.fechar();

Esse cache sobrevive a reinicializações da aplicação, mas não precisa de nenhuma infraestrutura externa. Perfeito para aplicações CLI, scripts de automação ou pequenos serviços.

Comparação de performance

Em testes práticos, o SQLite nativo do Node.js apresenta performance comparável ao better-sqlite3, às vezes até superior em operações simples.

A diferença se torna mais evidente no tempo de inicialização. Como não há dependência externa para carregar, o SQLite nativo está disponível instantaneamente.

Criei um banco, uma tabela e inseri um registro usando ambas as abordagens. Os resultados:

better-sqlite3: ~180ms para completar todas as operações.

SQLite nativo: ~45ms para as mesmas operações.

A diferença de 4x não é desprezível, especialmente em scripts que precisam executar rapidamente ou em testes automatizados que rodam centenas de vezes.

Limitações e considerações

O SQLite nativo é poderoso, mas tem suas limitações:

  • Ainda experimental: Na versão atual (22.5) do Node.js, a API ainda é considerada experimental. Pode haver mudanças antes de se tornar estável.
  • Concorrência limitada: SQLite não foi feito para múltiplos processos escrevendo simultaneamente. Para aplicações web com alta concorrência, considere PostgreSQL ou MySQL.
  • API síncrona: O DatabaseSync bloqueia a event loop durante operações. Para a maioria dos casos isso não é problema, mas em aplicações que precisam de alta performance assíncrona, pode ser uma limitação.
  • Sem ORM integrado: Você escreve SQL puro. Se está acostumado com Prisma ou Sequelize, vai precisar adaptar.

Para prototipagem, testes, aplicações CLI e casos de uso com baixa concorrência, essas limitações raramente são problemas.

Quando usar SQLite nativo

O SQLite nativo brilha em cenários específicos:

  • Testes automatizados: Banco em memória para cada suite de testes, isolamento total, sem configuração.
  • Prototipagem: Validar uma ideia rapidamente sem se preocupar com infraestrutura de banco.
  • Aplicações CLI: Scripts e ferramentas de linha de comando que precisam persistir dados localmente.
  • Cache persistent: Dados que precisam sobreviver a reinicializações mas não justificam um Redis.
  • Configurações e preferência:. Armazenar configurações de usuário de forma estruturada.
  • Aplicações embarcadas: IoT, dispositivos com recursos limitados, aplicações offline-first.

Quando não usar

Evite SQLite nativo para cenários que exigem robustez de banco de dados tradicional:

  • APIs web com alta concorrência: Múltiplos usuários fazendo escritas simultâneas vão criar gargalos.
  • Dados críticos de produção: Para dados de negócio importantes, prefira bancos com replicação e backup robusto.
  • Volumes muito grandes: SQLite funciona bem até alguns gigabytes, mas não é ideal para big data.
  • Quando você já tem infraestrutura: Se seu projeto já usa PostgreSQL, não faz sentido adicionar SQLite só porque é nativo.

Como começar

Primeiro, verifique sua versão do Node.js. O SQLite nativo está disponível a partir da versão 22.5. Rode node -v para confirmar.

Segundo, crie um arquivo de teste simples. Experimente criar um banco, uma tabela, inserir e consultar dados.

Terceiro, explore os casos de uso. Pense onde no seu dia a dia um banco local e simples resolveria problemas.

Quarto, acompanhe a evolução. Como a API ainda é experimental, fique de olho nas notas de release do Node.js para mudanças.

Conclusão

O Node.js continua evoluindo para ser um runtime cada vez mais completo. Ter SQLite integrado é mais um passo nessa direção.

Para desenvolvedores, isso significa menos fricção para começar projetos, menos dependências para gerenciar e menos problemas de compatibilidade entre sistemas operacionais.

Você não vai parar de usar PostgreSQL em produção. Mas vai poder prototipar mais rápido, escrever testes mais limpos e criar ferramentas locais sem instalar nada adicional.

Na próxima vez que precisar de um banco de dados simples, lembre que ele já está ali, esperando um import.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *