Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik24 min czytania

Turso, rozproszona baza SQLite blisko użytkownika

Turso to baza zgodna z SQLite z replikami osadzonymi w aplikacji. Przepisanie silnika w Rust, repliki lokalne, cennik i porównanie z Neonem.

Turso, rozproszona baza SQLite blisko użytkownika

Turso to baza danych zgodna z SQLite, uruchamiana jako usługa i zaprojektowana wokół jednego pomysłu: kopia danych ma leżeć jak najbliżej kodu, który je czyta. W skrajnym wariancie oznacza to replikę wewnątrz procesu aplikacji, więc odczyt nie wychodzi w ogóle na sieć. Projekt jest otwarty na licencji MIT.

Repliki osadzone, czyli o co tu naprawdę chodzi

Większość baz w chmurze rozwiązuje problem opóźnień, stawiając repliki w kilku regionach. Zapytanie nadal idzie po sieci, tylko krócej. Turso idzie o krok dalej i pozwala trzymać całą bazę jako plik w Twojej aplikacji, synchronizowany z instancją główną.

Konsekwencja jest jakościowa, a nie ilościowa. Odczyt przestaje być wywołaniem sieciowym i staje się odczytem z dysku lokalnego, więc mierzy się go w ułamkach milisekundy zamiast w dziesiątkach. Znika też cała klasa problemów: limit połączeń, chwilowe zerwania łącza, opóźnienie rosnące wraz z odległością od centrum danych.

Cena jest taka, że zapisy nadal idą do instancji głównej, a replika dowiaduje się o nich z opóźnieniem. Dostajesz więc model, w którym odczyt jest błyskawiczny, ale może pokazywać stan sprzed chwili. Do katalogu produktów, treści czy konfiguracji to układ idealny. Do licznika miejsc na sali albo stanu magazynowego przy ostatniej sztuce już nie, bo dwóch użytkowników zobaczy tę samą dostępność.

Druga cena to rozmiar. Skoro replika leży w aplikacji, musi się w niej zmieścić. Przy bazie o rozmiarze kilkuset megabajtów jest to bez znaczenia, przy kilkudziesięciu gigabajtach przestaje mieć sens, zwłaszcza w środowisku uruchamianym na żądanie, gdzie każdy zimny start oznacza pobranie pliku.

Przepisanie silnika w Rust

To najważniejsza zmiana ostatnich dwóch lat i zarazem rzecz, przez którą starsze opisy tego projektu wprowadzają w błąd.

Pierwotnie Turso opierało się na libSQL, czyli odgałęzieniu oryginalnego SQLite napisanego w C, do którego dołożono replikację i dostęp po HTTP. Ten projekt nadal istnieje, ma otwarty kod i jest utrzymywany, ostatnie zmiany w repozytorium pochodzą z lipca 2026 roku.

Równolegle zespół doszedł do wniosku, że część celów, przede wszystkim asynchroniczne wejście i wyjście oraz bezpieczeństwo pamięci, łatwiej osiągnąć, pisząc silnik od nowa, niż przerabiając kod sprzed dwóch dekad. Tak powstał projekt Limbo, czyli kompletne przepisanie SQLite w języku Rust, z zachowaniem zgodności dialektu SQL i formatu pliku. Później przemianowano go po prostu na Turso i to on jest dziś głównym kierunkiem rozwoju.

Konkretne zyski z tego przepisania są dwa i oba mają znaczenie praktyczne. Sterowanie współbieżnością oparte na wielu wersjach pozwala kilku piszącym postępować równolegle, co w oryginalnym SQLite było niemożliwe, bo zapis blokował całą bazę. Asynchroniczne wejście i wyjście przez mechanizm jądra Linuksa sprawia, że wątek nie stoi bezczynnie w oczekiwaniu na dysk, co ma znaczenie zwłaszcza w środowiskach rozliczanych za czas działania.

W lipcu 2026 roku projekt poszedł jeszcze dalej i dołożył eksperymentalną obsługę protokołu Postgresa. Opis repozytorium mówi wprost o architekturze, która ma pozwolić na wiele różnych warstw zewnętrznych nad jednym silnikiem.

Turso a inne bazy w chmurze

CechaTursoNeonPlanetScaleSupabase
Silnikzgodny z SQLitePostgreSQLMySQLPostgreSQL
Replika w aplikacjitaknienienie
Odczyt lokalnyułamki milisekundyprzez siećprzez siećprzez sieć
Rozgałęzianie bazytaktak, mocna stronatakograniczone
Otwarty kod silnikatak, MITczęściowonietak
Złożone zapytaniaograniczonepełny PostgreSQLpełny MySQLpełny PostgreSQL

Wybór sprowadza się do pytania o kształt obciążenia. Jeśli aplikacja głównie czyta, dane da się zmieścić w rozsądnym pliku, a zależy Ci na opóźnieniu, Turso wygrywa i to wyraźnie. Jeśli piszesz dużo, potrzebujesz złożonych zapytań z wieloma złączeniami albo funkcji, które daje dojrzały PostgreSQL, wybierz PostgreSQL w dowolnym wydaniu i nie komplikuj sobie życia.

Kluczowe zalety Turso

  1. Lokalizacje brzegowe - repliki rozsiane po świecie, blisko użytkownika
  2. Repliki osadzone - kopia bazy wewnątrz Twojej aplikacji
  3. libSQL - otwarte odgałęzienie SQLite z dodatkowymi możliwościami
  4. Bardzo niskie opóźnienia - mikrosekundy przy odczycie lokalnym
  5. Zgodność z SQLite - istniejące narzędzia i zapytania działają
  6. Bez zarządzania serwerem - skalowanie po stronie dostawcy
  7. Rozliczenie za wiersze - zamiast za czas działania serwera
  8. Prostota - jeden plik to cała baza

Turso a inne bazy w chmurze

CechaTursoPlanetScaleNeonD1 (Cloudflare)
SilnikSQLite/libSQLVitess i PostgresPostgreSQLSQLite
Repliki osadzoneTakNieNieNie
Gałęzie bazyTakTakTakNie
Otwarty silniklibSQL, takNieNieNie

Rozmiarów darmowych progów i liczb opisujących opóźnienia świadomie w tej tabeli nie ma. Zmieniają się kilka razy w roku, a przynajmniej jedna z powtarzanych powszechnie wartości jest po prostu nieprawdziwa: PlanetScale wycofał darmowy plan, więc jego wariant wejściowy zaczyna się od opłaty miesięcznej, a nie od zera. Aktualne limity sprawdź u każdego dostawcy przed decyzją, która się na nich opiera.

Turso vs SQLite

CechaTursoSQLite
ReplikacjaAutomatycznaBrak
Edge deploymentTakNie
HTTP accessTakNie
WebsocketsTakNie
SkalowanieAutomatyczneManualne
BackupyAutomatyczneManualne
Multi-regionTakNie

Kiedy wybrać Turso?

  • Edge applications - Dane blisko użytkowników
  • Read-heavy workloads - Większość operacji to odczyty
  • Low latency critical - Mikrosekundy mają znaczenie
  • Simple schema - Relacyjne dane bez złożonych JOINów
  • Cost-conscious - Tańsza alternatywa dla PostgreSQL

Instalacja i konfiguracja

Turso CLI

Code
Bash
# macOS / Linux
curl -sSfL https://get.tur.so/install.sh | bash

# macOS (Homebrew)
brew install tursodatabase/tap/turso

# Weryfikacja
turso --version

# Windows (WSL wymagany)
curl -sSfL https://get.tur.so/install.sh | bash

Logowanie i tworzenie bazy

Code
Bash
# Zaloguj się (otworzy przeglądarkę)
turso auth login

# Lub z tokenem
turso auth login --headless

# Stwórz nową bazę danych
turso db create my-database

# Z konkretną lokalizacją
turso db create my-database --location waw

# Lista baz
turso db list

# Szczegóły bazy
turso db show my-database

Dostępne lokalizacje (30+)

Code
Bash
# Lista wszystkich lokalizacji
turso db locations

# Popularne lokalizacje:
# waw - Warsaw, Poland
# fra - Frankfurt, Germany
# lhr - London, UK
# ams - Amsterdam, Netherlands
# cdg - Paris, France
# iad - N. Virginia, USA
# sfo - San Francisco, USA
# sin - Singapore
# nrt - Tokyo, Japan
# syd - Sydney, Australia
# gru - São Paulo, Brazil

Tworzenie tokenu

Code
Bash
# Token z pełnymi uprawnieniami
turso db tokens create my-database

# Token read-only
turso db tokens create my-database --read-only

# Token z expiration
turso db tokens create my-database --expiration 7d

# Revoke token
turso db tokens revoke my-database <token-name>

Podstawowe użycie

Turso CLI Shell

Code
Bash
# Połącz się z bazą (interaktywny shell)
turso db shell my-database

# Wykonaj SQL
turso> CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

turso> INSERT INTO users (email, name) VALUES ('user@example.com', 'John');

turso> SELECT * FROM users;

# Wyjście
turso> .quit

Klient TypeScript i JavaScript

Code
Bash
npm install @libsql/client
TSdb/client.ts
TypeScript
// db/client.ts
import { createClient } from '@libsql/client'

export const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

// Lub dla lokalnego developmentu
// export const db = createClient({
//   url: 'file:local.db'
// })
Code
TypeScript
// Podstawowe operacje
import { db } from './db/client'

// Query
const result = await db.execute('SELECT * FROM users')
console.log(result.rows)

// Z parametrami (named)
const user = await db.execute({
  sql: 'SELECT * FROM users WHERE id = :id',
  args: { id: 1 }
})

// Z parametrami (positional)
const users = await db.execute({
  sql: 'SELECT * FROM users WHERE email = ?',
  args: ['user@example.com']
})

// Insert
await db.execute({
  sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
  args: ['new@example.com', 'Jane']
})

// Update
await db.execute({
  sql: 'UPDATE users SET name = ? WHERE id = ?',
  args: ['Updated Name', 1]
})

// Delete
await db.execute({
  sql: 'DELETE FROM users WHERE id = ?',
  args: [1]
})

Transakcje

Code
TypeScript
// Pojedyncza transakcja
const result = await db.transaction(async (tx) => {
  await tx.execute({
    sql: 'INSERT INTO accounts (user_id, balance) VALUES (?, ?)',
    args: [userId, 1000]
  })

  await tx.execute({
    sql: 'INSERT INTO transactions (account_id, amount) VALUES (?, ?)',
    args: [accountId, -100]
  })

  await tx.execute({
    sql: 'UPDATE accounts SET balance = balance - ? WHERE id = ?',
    args: [100, accountId]
  })

  return { success: true }
})

Zapytania wsadowe

Code
TypeScript
// Wykonaj wiele queries w jednym roundtrip
const results = await db.batch([
  {
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: ['user1@example.com', 'User 1']
  },
  {
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: ['user2@example.com', 'User 2']
  },
  {
    sql: 'SELECT * FROM users'
  }
])

// results[0] - pierwszy INSERT
// results[1] - drugi INSERT
// results[2] - SELECT

Repliki brzegowe

Dodawanie replik

Code
Bash
# Dodaj replikę w konkretnej lokalizacji
turso db replicate my-database waw
turso db replicate my-database fra
turso db replicate my-database sin

# Lista replik
turso db show my-database

# Usuń replikę
turso db replicate my-database waw --remove

Kierowanie automatyczne

Code
TypeScript
// Klient automatycznie łączy się z najbliższą repliką
const db = createClient({
  url: 'libsql://my-database-username.turso.io',
  authToken: process.env.TURSO_AUTH_TOKEN
})

// Reads -> najbliższa replika
// Writes -> primary (propagowane do replik)

Kierowanie ręczne

Code
TypeScript
// Połączenie do konkretnej lokalizacji
const db = createClient({
  url: 'libsql://my-database-username.turso.io?location=waw',
  authToken: process.env.TURSO_AUTH_TOKEN
})

Repliki osadzone w praktyce

Repliki osadzone to lokalna kopia bazy trzymana w samej aplikacji.

Konfiguracja

Code
TypeScript
import { createClient } from '@libsql/client'

const db = createClient({
  // Lokalna replika
  url: 'file:local-replica.db',

  // Synchronizacja z remote
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,

  // Opcjonalnie: automatyczna synchronizacja
  syncInterval: 60  // sekundy
})

Synchronizacja ręczna

Code
TypeScript
// Synchronizuj ręcznie
await db.sync()

// Po INSERT/UPDATE na remote
// Dane pojawią się lokalnie po sync

// Typowy flow:
// 1. Użytkownik czyta z embedded replica (~0.5ms)
// 2. Użytkownik zapisuje do remote
// 3. Aplikacja sync()
// 4. Nowe dane dostępne lokalnie

Zastosowania replik osadzonych

Code
TypeScript
// 1. Read-heavy aplikacje
// Większość odczytów z lokalnej repliki
const users = await db.execute('SELECT * FROM users LIMIT 100')
// ~0.5ms zamiast ~50ms!

// 2. Offline-first apps
// Dane dostępne bez internetu
const cachedData = await db.execute('SELECT * FROM cache')

// 3. Edge functions
// Lokalna replika w Vercel Edge Function
export const config = { runtime: 'edge' }

export default async function handler() {
  const data = await db.execute('SELECT * FROM products')
  return Response.json(data.rows)
}

Repliki osadzone w Next.js

TSlib/db.ts
TypeScript
// lib/db.ts
import { createClient } from '@libsql/client'

// Singleton pattern dla embedded replica
let db: ReturnType<typeof createClient> | null = null

export function getDb() {
  if (!db) {
    db = createClient({
      url: 'file:./local.db',
      syncUrl: process.env.TURSO_DATABASE_URL,
      authToken: process.env.TURSO_AUTH_TOKEN
    })
  }
  return db
}

// Sync na starcie aplikacji
export async function initDb() {
  const db = getDb()
  await db.sync()
}
TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { getDb } from '@/lib/db'
import { NextResponse } from 'next/server'

export async function GET() {
  const db = getDb()

  // Super szybki odczyt z lokalnej repliki
  const result = await db.execute('SELECT * FROM users')

  return NextResponse.json(result.rows)
}

export async function POST(request: Request) {
  const db = getDb()
  const data = await request.json()

  // Zapis idzie do remote
  await db.execute({
    sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
    args: [data.email, data.name]
  })

  // Sync dla natychmiastowej dostępności lokalnie
  await db.sync()

  return NextResponse.json({ success: true })
}

Drizzle ORM

Konfiguracja

Code
Bash
npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
TSdb/schema.ts
TypeScript
// db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name'),
  createdAt: text('created_at').default('CURRENT_TIMESTAMP')
})

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  content: text('content'),
  authorId: integer('author_id').notNull().references(() => users.id),
  published: integer('published', { mode: 'boolean' }).default(false),
  createdAt: text('created_at').default('CURRENT_TIMESTAMP')
})
TSdb/index.ts
TypeScript
// db/index.ts
import { drizzle } from 'drizzle-orm/libsql'
import { createClient } from '@libsql/client'
import * as schema from './schema'

const client = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

export const db = drizzle(client, { schema })
TSdrizzle.config.ts
TypeScript
// drizzle.config.ts
import type { Config } from 'drizzle-kit'

export default {
  schema: './db/schema.ts',
  out: './drizzle',
  driver: 'turso',
  dbCredentials: {
    url: process.env.TURSO_DATABASE_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!
  }
} satisfies Config

Migracje

Code
Bash
# Generuj migracje
npx drizzle-kit generate:sqlite

# Aplikuj migracje
npx drizzle-kit push:sqlite

# Studio (GUI)
npx drizzle-kit studio

Zapytania przez Drizzle

Code
TypeScript
import { db } from '@/db'
import { users, posts } from '@/db/schema'
import { eq, and, desc, like } from 'drizzle-orm'

// Select all
const allUsers = await db.select().from(users)

// Select with where
const user = await db
  .select()
  .from(users)
  .where(eq(users.id, 1))

// Select specific columns
const emails = await db
  .select({ email: users.email })
  .from(users)

// Join
const postsWithAuthors = await db
  .select({
    post: posts,
    author: users
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.published, true))
  .orderBy(desc(posts.createdAt))

// Insert
const [newUser] = await db
  .insert(users)
  .values({
    email: 'new@example.com',
    name: 'New User'
  })
  .returning()

// Update
await db
  .update(users)
  .set({ name: 'Updated' })
  .where(eq(users.id, 1))

// Delete
await db
  .delete(users)
  .where(eq(users.id, 1))

// Search
const searchResults = await db
  .select()
  .from(users)
  .where(like(users.name, '%john%'))

Prisma

Konfiguracja

Code
Bash
npm install prisma @prisma/client
npm install @prisma/adapter-libsql @libsql/client
npx prisma init
prisma/schema.prisma
Prisma
// prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
}
TSlib/prisma.ts
TypeScript
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
import { PrismaLibSQL } from '@prisma/adapter-libsql'
import { createClient } from '@libsql/client'

const libsql = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

const adapter = new PrismaLibSQL(libsql)

export const prisma = new PrismaClient({ adapter })
Code
Bash
# Push schema do Turso
npx prisma db push

# Generuj client
npx prisma generate
Code
TypeScript
// Użycie
import { prisma } from '@/lib/prisma'

// Create
const user = await prisma.user.create({
  data: {
    email: 'user@example.com',
    name: 'John'
  }
})

// Read with relations
const posts = await prisma.post.findMany({
  where: { published: true },
  include: { author: true }
})

// Update
await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Updated' }
})

// Delete
await prisma.post.delete({
  where: { id: 1 }
})

Grupy baz danych

Groups pozwalają zarządzać wieloma bazami razem:

Code
Bash
# Stwórz grupę
turso group create my-group --location waw

# Dodaj bazę do grupy
turso db create my-db --group my-group

# Wszystkie bazy w grupie dziedziczą lokalizacje
turso group locations add my-group fra

# Lista grup
turso group list

# Tokeny na poziomie grupy
turso group tokens create my-group

Architektura wielodostępna

Code
TypeScript
// Każdy tenant ma własną bazę w grupie
async function createTenantDb(tenantId: string) {
  // Użyj Turso Platform API
  const response = await fetch(
    `https://api.turso.tech/v1/organizations/${orgId}/databases`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${TURSO_API_TOKEN}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: `tenant-${tenantId}`,
        group: 'tenants-group'
      })
    }
  )

  return response.json()
}

// Routing do właściwej bazy
function getTenantDb(tenantId: string) {
  return createClient({
    url: `libsql://tenant-${tenantId}-${org}.turso.io`,
    authToken: process.env.TURSO_AUTH_TOKEN
  })
}

Zarządzanie schematem

Schemat przez wiersz poleceń

Code
Bash
# Pokaż schemat
turso db shell my-database ".schema"

# Export schema
turso db shell my-database ".schema" > schema.sql

# Import schema
turso db shell my-database < schema.sql

Migracje z Drizzle

Code
Bash
# Struktura
migrations/
├── 0000_init.sql
├── 0001_add_posts.sql
└── 0002_add_comments.sql
TSmigrate.ts
TypeScript
// migrate.ts
import { migrate } from 'drizzle-orm/libsql/migrator'
import { db } from './db'

async function main() {
  console.log('Running migrations...')
  await migrate(db, { migrationsFolder: './drizzle' })
  console.log('Migrations complete!')
}

main()

Zrzut i odtworzenie schematu

Code
Bash
# Dump całej bazy
turso db shell my-database ".dump" > backup.sql

# Restore
turso db create my-database-restored
turso db shell my-database-restored < backup.sql

Integracje

Next.js App Router

TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { NextResponse } from 'next/server'

export async function GET() {
  const allUsers = await db.select().from(users)
  return NextResponse.json(allUsers)
}

export async function POST(request: Request) {
  const data = await request.json()

  const [user] = await db
    .insert(users)
    .values(data)
    .returning()

  return NextResponse.json(user, { status: 201 })
}

Vercel Edge Functions

TSapp/api/edge/route.ts
TypeScript
// app/api/edge/route.ts
import { createClient } from '@libsql/client/web'

export const runtime = 'edge'

const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const id = searchParams.get('id')

  const result = await db.execute({
    sql: 'SELECT * FROM users WHERE id = ?',
    args: [id]
  })

  return Response.json(result.rows[0])
}

SvelteKit

TSsrc/lib/db.ts
TypeScript
// src/lib/db.ts
import { createClient } from '@libsql/client'
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private'

export const db = createClient({
  url: TURSO_DATABASE_URL,
  authToken: TURSO_AUTH_TOKEN
})
TSsrc/routes/api/users/+server.ts
TypeScript
// src/routes/api/users/+server.ts
import { db } from '$lib/db'
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = async () => {
  const result = await db.execute('SELECT * FROM users')
  return json(result.rows)
}

Remix

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { createClient } from '@libsql/client'

export const db = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})
TSapp/routes/users.tsx
TypeScript
// app/routes/users.tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

export async function loader() {
  const result = await db.execute('SELECT * FROM users')
  return json({ users: result.rows })
}

export default function Users() {
  const { users } = useLoaderData<typeof loader>()

  return (
    <ul>
      {users.map((user: any) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

GitHub Actions CI/CD

.github/workflows/migrate.yml
YAML
# .github/workflows/migrate.yml
name: Database Migration

on:
  push:
    branches: [main]
    paths: ['drizzle/**']

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Run migrations
        run: npx drizzle-kit push:sqlite
        env:
          TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
          TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}

Wskazówki wydajnościowe

Indeksy

Code
SQL
-- Twórz indeksy dla często używanych kolumn
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);

-- Composite index
CREATE INDEX idx_posts_author_published
ON posts(author_id, published);

Optymalizacja zapytań

Code
TypeScript
// Zle - pobiera wszystko
const users = await db.execute('SELECT * FROM users')

// Dobrze - tylko potrzebne kolumny
const users = await db.execute('SELECT id, name FROM users')

// Zle - brak limitu
const posts = await db.execute('SELECT * FROM posts ORDER BY created_at DESC')

// Dobrze - z limitem
const posts = await db.execute({
  sql: 'SELECT * FROM posts ORDER BY created_at DESC LIMIT ?',
  args: [20]
})

// Dobrze - stronicowanie
const posts = await db.execute({
  sql: 'SELECT * FROM posts ORDER BY id LIMIT ? OFFSET ?',
  args: [20, page * 20]
})

Ponowne użycie połączenia

Code
TypeScript
// Zle - nowy klient przy kazdym zadaniu
export async function handler() {
  const db = createClient({ ... })
  // ...
}

// Dobrze - jedna instancja klienta
let db: ReturnType<typeof createClient> | null = null

export function getDb() {
  if (!db) {
    db = createClient({
      url: process.env.TURSO_DATABASE_URL!,
      authToken: process.env.TURSO_AUTH_TOKEN!
    })
  }
  return db
}

Zapytanie wsadowe kontra wiele zapytań

Code
TypeScript
// Zle - wiele obiegow do bazy
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })
await db.execute({ sql: 'INSERT INTO users ...', args: [...] })

// Dobrze - jeden obieg do bazy
await db.batch([
  { sql: 'INSERT INTO users ...', args: [...] },
  { sql: 'INSERT INTO users ...', args: [...] },
  { sql: 'INSERT INTO users ...', args: [...] }
])

Cennik

PlanCena miesięcznieBazyMiejsceOdczyty wierszyZapisy wierszy
Free0 USD1005 GB500 mln10 mln
Developer4,99 USDbez limitu9 GB2,5 mld25 mln
Scaler24,92 USDbez limitu24 GB100 mld100 mln
Pro416,58 USDbez limitu50 GB250 mld250 mln

Model rozliczeń jest tu inny niż w większości baz i to jego zrozumienie decyduje o tym, czy rachunek Cię zaskoczy. Nie płacisz za czas działania serwera, tylko za odczytane i zapisane wiersze. Przekroczenie limitu nie blokuje bazy, tylko dokłada opłatę: około jednego dolara za miliard dodatkowych odczytów i za milion dodatkowych zapisów, przy czym stawki maleją na wyższych planach.

Trzecia metryka, o której łatwo zapomnieć przy dwóch pozostałych, to miejsce na dane. Powyżej progu zawartego w planie płacisz od siedemdziesięciu pięciu centów za gigabajt na planie deweloperskim, przez pięćdziesiąt centów, do czterdziestu pięciu na najwyższym. Przy bazie rosnącej liniowo ta pozycja jest jedyną, która nie maleje po optymalizacji zapytań, więc warto ją śledzić osobno.

Warto zauważyć asymetrię między odczytem a zapisem, bo mówi wszystko o przeznaczeniu tej bazy. W planie darmowym mieści się pięćset milionów odczytów i tylko dziesięć milionów zapisów, czyli pięćdziesięciokrotnie mniej. To nie jest przeoczenie, tylko odzwierciedlenie architektury: baza jest zaprojektowana pod obciążenie zdominowane przez czytanie.

Osobną pozycją jest synchronizacja replik osadzonych, rozliczana za przesłane gigabajty. Przy replice odświeżanej często i w wielu instancjach aplikacji ta pozycja potrafi urosnąć bardziej niż same odczyty, więc przy planowaniu policz również, jak często i w ilu miejscach będziesz synchronizować.

Praktyczna rada: policz zapytania, zanim wybierzesz plan. Jedno wyświetlenie strony, które wykonuje pięć zapytań zwracających po dwadzieścia wierszy, to sto odczytanych wierszy. Przy milionie odsłon miesięcznie mówimy o stu milionach odczytów, czyli jeszcze w granicach planu darmowego, ale już w tym samym rzędzie wielkości.

Dobre praktyki

Przebieg pracy przy rozwoju

Code
Bash
# 1. Lokalna baza do developmentu
turso db create my-app-dev

# 2. Staging
turso db create my-app-staging

# 3. Production
turso db create my-app-prod --location waw
turso db replicate my-app-prod fra
turso db replicate my-app-prod iad

Zmienne środowiskowe

.env.local
ENV
# .env.local (development)
TURSO_DATABASE_URL=libsql://my-app-dev-username.turso.io
TURSO_AUTH_TOKEN=eyJhbGci...

# .env.production
TURSO_DATABASE_URL=libsql://my-app-prod-username.turso.io
TURSO_AUTH_TOKEN=eyJhbGci...

Obsługa błędów

Code
TypeScript
import { db } from '@/lib/db'

async function getUser(id: number) {
  try {
    const result = await db.execute({
      sql: 'SELECT * FROM users WHERE id = ?',
      args: [id]
    })

    if (result.rows.length === 0) {
      return null
    }

    return result.rows[0]
  } catch (error) {
    if (error instanceof Error) {
      console.error('Database error:', error.message)

      // Retry logic dla network errors
      if (error.message.includes('network')) {
        // Retry...
      }
    }
    throw error
  }
}

Bezpieczeństwo typów

TStypes/db.ts
TypeScript
// types/db.ts
interface User {
  id: number
  email: string
  name: string | null
  createdAt: string
}

// Typowany query
async function getUsers(): Promise<User[]> {
  const result = await db.execute('SELECT * FROM users')
  return result.rows as User[]
}

// Lub z Drizzle (pełna type safety)
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'

const allUsers = await db.select().from(users)
// allUsers jest automatycznie typowany!

FAQ - najczęściej zadawane pytania

Kiedy używać embedded replicas?

Gdy masz read-heavy workload i zależy Ci na ultra-niskich latencjach. Idealne dla e-commerce (produkty), CMS (artykuły), analytics dashboards.

Czy Turso wspiera foreign keys?

Tak! W przeciwieństwie do PlanetScale, Turso/libSQL w pełni wspiera SQLite foreign keys.

Jak działa synchronizacja embedded replicas?

Embedded replica synchronizuje się z primary przy wywołaniu db.sync(). Możesz też ustawić syncInterval dla automatycznej synchronizacji.

Czy mogę używać Turso z istniejącą bazą SQLite?

Tak! Możesz zaimportować istniejącą bazę SQLite do Turso przez CLI lub API.

Jaka jest różnica między Turso a D1 (Cloudflare)?

D1 działa tylko na Cloudflare Workers. Turso jest niezależny od platformy i oferuje embedded replicas oraz więcej edge locations.

Czy Turso wspiera full-text search?

Tak, przez SQLite FTS5 extension. libSQL wspiera wszystkie standardowe rozszerzenia SQLite.

Code
SQL
-- FTS5 przykład
CREATE VIRTUAL TABLE posts_fts USING fts5(title, content);
INSERT INTO posts_fts SELECT title, content FROM posts;
SELECT * FROM posts_fts WHERE posts_fts MATCH 'search query';

Czy odczyt z repliki zwraca aktualne dane?

Nie zawsze, i to jest podstawowe ograniczenie tego modelu. Replika synchronizuje się z instancją główną, więc między zapisem a jego widocznością lokalnie mija chwila. Tam, gdzie potrzebujesz pewności, kieruj odczyt bezpośrednio do instancji głównej.

Czym różni się Turso od libSQL?

libSQL to odgałęzienie oryginalnego SQLite w języku C, na którym usługa opierała się wcześniej i które nadal jest utrzymywane. Turso to nazwa zarówno firmy i usługi, jak i nowego silnika napisanego od zera w Rust, który jest dziś głównym kierunkiem rozwoju.

Kiedy replika osadzona ma sens, a kiedy nie

Ta decyzja wraca w każdym wdrożeniu i warto podjąć ją świadomie, bo od niej zależy cała architektura, a nie tylko konfiguracja połączenia.

Replika osadzona ma sens wtedy, gdy spełnione są trzy warunki naraz. Baza jest na tyle mała, że jej pobranie nie boli, aplikacja żyje na tyle długo, że pobranie zdarza się rzadko, a odczyty stanowią zdecydowaną większość ruchu. Klasyczny przykład to serwis z treścią, gdzie artykuły zmieniają się kilka razy dziennie, a czytane są tysiące razy.

Traci sens, gdy któryś z tych warunków nie jest spełniony. W środowisku, które podnosi nową instancję przy każdym żądaniu, replika nie zdąży się przydać, bo koszt pobrania rozłoży się na jedno wywołanie. Przy bazie rosnącej do gigabajtów pobranie przestaje być operacją w tle i staje się problemem. Przy obciążeniu zdominowanym przez zapisy replika jedynie dokłada opóźnienie synchronizacji, nie dając nic w zamian.

Jest też wariant pośredni, o którym często się zapomina, a który bywa najrozsądniejszy. Możesz korzystać z bazy przez zwykłe połączenie sieciowe, bez repliki lokalnej, i nadal zyskujesz prostotę modelu oraz rozliczenie za wiersze zamiast za czas serwera. Replika jest funkcją opcjonalną, nie warunkiem korzystania z usługi, i dołożenie jej później nie wymaga zmiany schematu ani zapytań.

Praktyczna kolejność wdrożenia wygląda więc tak: zacznij od połączenia sieciowego, zmierz opóźnienia na realnym ruchu i dopiero wtedy zdecyduj, czy replika lokalna coś zmieni. Odwrotna kolejność, czyli zaczynanie od repliki, bo brzmi ciekawie, kończy się zwykle mierzeniem się z zimnym startem przy problemie, którego nie było.

Typowe błędy

Pierwszy to zakładanie natychmiastowej spójności. Kod, który zapisuje rekord, a zaraz potem odczytuje go z repliki, będzie działał poprawnie w dziewięciu przypadkach na dziesięć i zawiedzie w dziesiątym, zwykle na produkcji. Po zapisie albo czytaj z instancji głównej, albo pracuj na wartości, którą właśnie zapisałeś.

Drugi to lekceważenie kosztu zimnego startu przy replikach osadzonych. Nowa instancja aplikacji musi pobrać plik bazy, zanim odpowie na pierwsze żądanie. Przy bazie o rozmiarze stu megabajtów i środowisku, które podnosi instancje na żądanie, ten koszt bywa większy niż oszczędność na opóźnieniu.

Trzeci to liczenie odczytanych wierszy, a nie zapytań. Zapytanie bez indeksu, które przegląda całą tabelę, zużywa tyle odczytów, ile jest w niej wierszy, nawet jeśli zwróci jeden. Brakujący indeks przekłada się tu bezpośrednio na rachunek, a nie tylko na czas odpowiedzi.

Czwarty to traktowanie tej bazy jak pełnego PostgreSQL. Dialekt SQLite jest węższy, typowanie działa inaczej, a części funkcji okienkowych i typów danych po prostu nie ma. Sprawdź, czy Twoje zapytania mieszczą się w tym dialekcie, zanim zaplanujesz migrację.

Piąty to trzymanie w niej danych, które rosną bez ograniczeń. Rejestry zdarzeń, historia zmian czy dane pomiarowe szybko przekroczą rozmiar, przy którym replika osadzona ma sens. Takie zbiory trzymaj gdzie indziej, a w tej bazie zostaw to, co aplikacja realnie czyta przy każdym żądaniu.

Kod silnika i wydania znajdziesz w repozytorium Turso, a starszy silnik w repozytorium libSQL.