Skip to content

Database

Complete guide for database operations in Laju framework.

Overview

Laju provides two database services:

ServiceUse CasePerformance
DB (Kysely)Complex queries, migrationsStandard
SQLite (Native)Simple reads, performance-critical2-4x faster

Both use BetterSQLite3 with WAL mode enabled by default.

DB Service (Kysely)

Kysely is a type-safe SQL query builder for TypeScript. It provides unparalleled autocompletion and compile-time type safety for complex queries.

Basic Queries

typescript
import DB from "app/services/DB";

// SELECT all
const users = await DB.selectFrom("users").selectAll().execute();

// SELECT with WHERE
const user = await DB.selectFrom("users")
  .selectAll()
  .where("email", "=", email)
  .executeTakeFirst();

const activeUsers = await DB.selectFrom("users")
  .selectAll()
  .where("is_active", "=", true)
  .execute();

// SELECT with multiple conditions
const results = await DB.selectFrom("posts")
  .selectAll()
  .where("status", "=", "published")
  .where("views", ">", 1000)
  .orderBy("created_at", "desc")
  .limit(10)
  .execute();

INSERT

typescript
// Basic insert
await DB.insertInto("posts")
  .values({
    title: "Hello World",
    content: "Post content",
    created_at: Date.now(),
    updated_at: Date.now()
  })
  .execute();

// Insert and get result
const result = await DB.insertInto("users")
  .values({
    name: "John Doe",
    email: "[email protected]"
  })
  .executeTakeFirst();
console.log(result.insertId); // Inserted row ID

// Batch insert
await DB.insertInto("logs").values([
  { message: "Log 1", created_at: Date.now() },
  { message: "Log 2", created_at: Date.now() },
  { message: "Log 3", created_at: Date.now() }
]).execute();

UPDATE

typescript
await DB.updateTable("users")
  .set({
    name: "New Name",
    updated_at: Date.now()
  })
  .where("id", "=", userId)
  .execute();

// Update multiple rows
await DB.updateTable("posts")
  .set({ status: "archived" })
  .where("status", "=", "draft")
  .execute();

DELETE

typescript
await DB.deleteFrom("sessions")
  .where("id", "=", sessionId)
  .execute();

// Delete with multiple conditions
await DB.deleteFrom("tokens")
  .where("expires_at", "<", new Date().toISOString())
  .execute();

JOIN

typescript
const posts = await DB.selectFrom("posts")
  .innerJoin("users", "posts.user_id", "users.id")
  .select(["posts.id", "posts.title", "users.name as author"])
  .execute();

// Left join
const users = await DB.selectFrom("users")
  .leftJoin("profiles", "users.id", "profiles.user_id")
  .select(["users.id", "users.name", "profiles.bio"])
  .execute();

WHERE Variations

typescript
// OR WHERE
const results = await DB.selectFrom("users")
  .selectAll()
  .where((eb) => eb.or([
    eb("role", "=", "admin"),
    eb("role", "=", "moderator")
  ]))
  .execute();

// WHERE IN
const results = await DB.selectFrom("posts")
  .selectAll()
  .where("category_id", "in", [1, 2, 3])
  .execute();

// WHERE NOT
const results = await DB.selectFrom("users")
  .selectAll()
  .where("status", "!=", "banned")
  .execute();

// WHERE NULL
const results = await DB.selectFrom("users")
  .selectAll()
  .where("deleted_at", "is", null)
  .execute();

// LIKE search
const results = await DB.selectFrom("users")
  .selectAll()
  .where("name", "like", "%john%")
  .execute();

Aggregates

typescript
// COUNT
const result = await DB.selectFrom("users")
  .select((eb) => eb.fn.countAll().as("count"))
  .executeTakeFirst();
console.log(result?.count);

// SUM
const result = await DB.selectFrom("orders")
  .select((eb) => eb.fn.sum("amount").as("total"))
  .executeTakeFirst();

// AVG
const result = await DB.selectFrom("products")
  .select((eb) => eb.fn.avg("price").as("average"))
  .executeTakeFirst();

// MIN/MAX
const minResult = await DB.selectFrom("products")
  .select((eb) => eb.fn.min("price").as("lowest"))
  .executeTakeFirst();

const maxResult = await DB.selectFrom("products")
  .select((eb) => eb.fn.max("price").as("highest"))
  .executeTakeFirst();

Transactions

typescript
await DB.transaction().execute(async (trx) => {
  const userResult = await trx.insertInto("users")
    .values({ name: "John" })
    .executeTakeFirst();
  
  const userId = userResult.insertId?.toString();
  
  await trx.insertInto("profiles")
    .values({ user_id: userId, bio: "Hello" })
    .execute();
    
  await trx.insertInto("settings")
    .values({ user_id: userId, theme: "dark" })
    .execute();
});

Raw Queries

typescript
import { sql } from "kysely";

const results = await sql<{
  id: string;
  name: string;
}>`SELECT * FROM users WHERE email = ${email}`.execute(DB);

// Raw in select
const users = await DB.selectFrom("users")
  .select([
    "id",
    sql<number>`COUNT(*) OVER()`.as("total_count")
  ])
  .execute();

Multiple Connections

typescript
const stagingDB = DB.connection("staging");
const users = await stagingDB.selectFrom("users").selectAll().execute();

SQLite Service (Native)

Direct better-sqlite3 access for maximum performance.

When to Use

Use Native SQLiteUse Kysely
Simple reads (2-4x faster)Complex query building
Performance-critical pathsType-safe queries
Bulk operationsDeveloper productivity
Direct SQL controlComplex joins and subqueries

Basic Methods

typescript
import SQLite from "app/services/SQLite";

// Get single row
const user = SQLite.get(
  'SELECT * FROM users WHERE email = ?',
  ['[email protected]']
);

// Get all rows
const posts = SQLite.all(
  'SELECT * FROM posts ORDER BY created_at DESC'
);

// Execute (INSERT, UPDATE, DELETE)
const result = SQLite.run(
  'INSERT INTO posts (title, content) VALUES (?, ?)',
  ['Title', 'Content']
);
console.log('Inserted ID:', result.lastInsertRowid);
console.log('Changes:', result.changes);

Prepared Statements

typescript
// Reuse statement for bulk operations
const insert = SQLite.prepare('INSERT INTO logs (message) VALUES (?)');
for (const msg of messages) {
  insert.run(msg);
}

Transactions

typescript
const insertMany = SQLite.transaction((items) => {
  const insert = SQLite.prepare('INSERT INTO items (name) VALUES (?)');
  for (const item of items) {
    insert.run(item.name);
  }
});
insertMany(items);

Migrations

Laju uses Kysely's built-in migration system.

Create Migration

Create a new file in the migrations/ folder with the naming convention:

migrations/YYYYMMDDhhmmss_description.ts

Example:

typescript
// migrations/20240101000000_create_posts_table.ts
import { Kysely, sql } from "kysely";

export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .createTable("posts")
    .addColumn("id", "serial", (col) => col.primaryKey())
    .addColumn("title", "varchar", (col) => col.notNull())
    .addColumn("content", "text")
    .addColumn("user_id", "integer", (col) =>
      col.references("users.id").onDelete("cascade")
    )
    .addColumn("is_published", "boolean", (col) => col.defaultTo(false))
    .addColumn("created_at", "bigint")
    .addColumn("updated_at", "bigint")
    .execute();
    
  // Add indexes
  await db.schema
    .createIndex("posts_user_id_idx")
    .on("posts")
    .column("user_id")
    .execute();
    
  await db.schema
    .createIndex("posts_is_published_created_at_idx")
    .on("posts")
    .columns(["is_published", "created_at"])
    .execute();
}

export async function down(db: Kysely<any>): Promise<void> {
  await db.schema.dropTable("posts").execute();
}

Run Migrations

bash
# Run all pending migrations
npm run migrate

# Database refresh (interactive)
npm run refresh

Rollback Migrations

bash
# Rollback 1 migration (default)
npm run migrate:down

# Rollback N migrations
npm run migrate:down 3

# Rollback to specific migration 
npm run migrate:down 20230514062913_sessions
npm run migrate:down 20230514062913_sessions.ts

Database Refresh

The db:refresh command allows you to selectively refresh a specific database by deleting its SQLite file and re-running migrations.

bash
# Interactive mode - prompts you to select a database
npm run refresh

# Direct selection - refresh specific database
npm run refresh 1    # Development
npm run refresh 2    # Production
npm run refresh 3    # Test

Performance Tips

1. Use Native SQLite for Simple Reads

typescript
// Standard (Kysely)
const user = await DB.selectFrom("users")
  .selectAll()
  .where("id", "=", id)
  .executeTakeFirst();

// Fast (Native) - 2-4x faster
const user = SQLite.get("SELECT * FROM users WHERE id = ?", [id]);

2. Add Indexes

typescript
// In migration
await db.schema
  .createIndex("users_email_idx")
  .on("users")
  .column("email")
  .execute();

// Composite index
await db.schema
  .createIndex("posts_user_id_created_at_idx")
  .on("posts")
  .columns(["user_id", "created_at"])
  .execute();

// Unique index
await db.schema
  .createIndex("users_email_unique_idx")
  .on("users")
  .column("email")
  .unique()
  .execute();

3. Use Transactions for Bulk Operations

typescript
// Slow - individual inserts
for (const item of items) {
  await DB.insertInto("items").values(item).execute();
}

// Fast - batch insert
await DB.insertInto("items").values(items).execute();

// Or use transaction
await DB.transaction().execute(async (trx) => {
  for (const item of items) {
    await trx.insertInto("items").values(item).execute();
  }
});

4. Use executeTakeFirst() for Single Records

typescript
// Correct
const user = await DB.selectFrom("users")
  .selectAll()
  .where("id", "=", id)
  .executeTakeFirst();

// Avoid - returns array
const [user] = await DB.selectFrom("users")
  .selectAll()
  .where("id", "=", id)
  .limit(1)
  .execute();

5. Select Only Needed Columns

typescript
// Good - select specific columns
const users = await DB.selectFrom("users")
  .select(["id", "name", "email"])
  .execute();

// Acceptable when you need all columns
const users = await DB.selectFrom("users")
  .selectAll()
  .execute();

Next Steps

Released under the MIT License.