🚧 VeloxKit is pre-release software. APIs may change before v1.0. Get started →
Documentation
Guides
Database Patterns

Database

VeloxKit embeds SQLite 3 with WAL mode enabled by default. The db API is fully async and works from any component or JS plugin.

Requires capability: add "db": true to veloxkit.config.json.

veloxkit.config.json
{
  "capabilities": { "db": true }
}

Opening a database

import { db } from '@velox/react';
 
// Call once on app start — auto-sets the default handle.
await db.open('app.db');
 
// Or keep the handle for multi-DB apps:
const usersDb = await db.open('users.db');
const logsDb  = await db.open('logs.db');
db.setDefault(logsDb);

db.open() creates the file if it doesn't exist. Use ":memory:" for an in-memory database.


Migrations

db.migrate() tracks applied versions in _velox_migrations and only runs pending entries. Each migration is committed atomically together with its tracking record — a partial failure leaves the database clean.

await db.open('app.db');
 
await db.migrate([
  {
    version: 1,
    name:    'create_notes',
    up: `CREATE TABLE notes (
      id         INTEGER PRIMARY KEY AUTOINCREMENT,
      title      TEXT    NOT NULL DEFAULT '',
      body       TEXT    NOT NULL DEFAULT '',
      pinned     INTEGER NOT NULL DEFAULT 0,
      created_at INTEGER NOT NULL DEFAULT (unixepoch()),
      updated_at INTEGER NOT NULL DEFAULT (unixepoch())
    )`,
  },
  {
    version: 2,
    name:    'add_fts',
    // Multiple statements — pass an array:
    up: [
      `CREATE VIRTUAL TABLE notes_fts USING fts5(title, body, content=notes, content_rowid=id)`,
      `CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
         INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
       END`,
      `CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
         INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
         INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
       END`,
      `CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
         INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
       END`,
    ],
  },
  {
    version: 3,
    name:    'add_tags',
    up: [
      `CREATE TABLE tags (
         id   INTEGER PRIMARY KEY AUTOINCREMENT,
         name TEXT NOT NULL UNIQUE
       )`,
      `CREATE TABLE note_tags (
         note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
         tag_id  INTEGER NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
         PRIMARY KEY (note_id, tag_id)
       )`,
    ],
  },
]);

up is a string (single statement) or an array of strings (multiple statements). Migrations run in ascending version order; already-applied versions are skipped.

Call db.migrate() before rendering your first screen. The recommended pattern is to run it in a startup useEffect or in the root component before mounting children.

Inspecting migration history

The tracking table is just a regular SQLite table:

const history = await db.query('SELECT version, name, applied_at FROM _velox_migrations ORDER BY version');
console.log(history);
// [{ version: 1, name: 'create_notes', applied_at: 1718000000 }, ...]

Seeding

db.seed() runs setup data after migrations. Two modes:

CallBehaviour
db.seed(fn)Always runs — write idempotent SQL (INSERT OR IGNORE)
db.seed(name, fn)Tracked in _velox_seeds, runs once per name
// Always run — idempotent defaults:
await db.seed(async () => {
  await db.run(
    'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)',
    ['theme', 'dark']
  );
  await db.run(
    'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)',
    ['font_size', '16']
  );
});
 
// Run once — dev sample data:
await db.seed('sample_notes', async () => {
  await db.run(
    'INSERT INTO notes (title, body) VALUES (?, ?)',
    ['Welcome to VeloxKit', 'Edit this note or create a new one.']
  );
});

The tracked seed is skipped on every subsequent app launch.


Recommended startup pattern

Put migrations and seeding together in a single async function called before rendering:

// src/db.js
import { db } from '@velox/react';
 
export async function initDb() {
  await db.open('app.db');
 
  await db.migrate([
    { version: 1, name: 'initial_schema', up: `
      CREATE TABLE notes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL DEFAULT '',
        body  TEXT NOT NULL DEFAULT '',
        updated_at INTEGER NOT NULL DEFAULT (unixepoch())
      )
    `},
  ]);
 
  await db.seed('sample_notes', async () => {
    await db.run("INSERT INTO notes (title) VALUES (?)", ['My first note']);
  });
}
// src/App.jsx
import { useState, useEffect } from 'react';
import { View, Text } from '@velox/react';
import { initDb } from './db';
import { NotesScreen } from './NotesScreen';
 
export default function App() {
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    initDb().then(() => setReady(true));
  }, []);
 
  if (!ready) return <View><Text>Loading…</Text></View>;
  return <NotesScreen />;
}

Query patterns

Select all rows

const notes = await db.query(
  'SELECT * FROM notes ORDER BY updated_at DESC'
);

Select with parameters

const note = await db.query(
  'SELECT * FROM notes WHERE id = ?', [id]
);
// note[0] or undefined

Insert and get new ID

const { lastInsertId } = await db.run(
  'INSERT INTO notes (title, body) VALUES (?, ?)',
  ['My note', 'Content here']
);

Update

await db.run(
  'UPDATE notes SET body = ?, updated_at = unixepoch() WHERE id = ?',
  [newBody, id]
);

Delete

await db.run('DELETE FROM notes WHERE id = ?', [id]);

Full-text search (after FTS5 virtual table is set up)

async function searchNotes(query) {
  if (!query.trim()) {
    return db.query('SELECT * FROM notes ORDER BY updated_at DESC');
  }
  return db.query(
    `SELECT notes.*
     FROM notes_fts
     JOIN notes ON notes.id = notes_fts.rowid
     WHERE notes_fts MATCH ?
     ORDER BY rank`,
    [query]
  );
}

Atomic transaction

await db.transaction([
  { sql: 'INSERT INTO archived_notes SELECT * FROM notes WHERE id = ?', params: [id] },
  { sql: 'DELETE FROM notes WHERE id = ?', params: [id] },
]);

Any statement failure rolls back the entire transaction.


Using a JS plugin (recommended for complex data layers)

For non-trivial apps, keep all SQL inside a JS plugin so the React side stays clean:

veloxkit.config.json
{
  "capabilities": { "db": true },
  "plugins": [
    { "entry": "src/plugins/notes.plugin.js", "name": "notes" }
  ]
}
src/plugins/notes.plugin.js
import { db } from '@velox/react';
 
await db.open('app.db');
await db.migrate([{ version: 1, name: 'create_notes', up: `CREATE TABLE notes (...)` }]);
await db.seed('defaults', async () => { /* ... */ });
 
export async function getAll() {
  return db.query('SELECT * FROM notes ORDER BY updated_at DESC');
}
 
export async function create(args) {
  const { lastInsertId } = await db.run(
    'INSERT INTO notes (title) VALUES (?)', [args.title]
  );
  return { id: lastInsertId };
}
src/NotesScreen.jsx
import { backend } from '@velox/react';
 
const notes = await backend.notes.getAll();
await backend.notes.create({ title: 'New note' });

Multiple databases

const mainDb = await db.open('main.db');
const cacheDb = await db.open('cache.db');
 
// Pass the handle explicitly for non-default databases:
await db.migrate(cacheDb, [
  { version: 1, up: 'CREATE TABLE cache (key TEXT PRIMARY KEY, value TEXT, ttl INTEGER)' },
]);
 
await db.run(cacheDb, 'INSERT INTO cache (key, value, ttl) VALUES (?, ?, ?)',
  ['user_profile', JSON.stringify(profile), Date.now() + 86400_000]);
 
// Close when done:
await db.close(cacheDb);