Arcwayv0.3.0

Database

Knex-based SQL database with migrations, seeds, transactions, and schema introspection

Access

Database access uses the db property on ctx — a full Knex instance with read-write access:

export const GET = {
  handler: async (ctx) => {
    const { db } = ctx;
    const users = await db('users').select('*');
    return { data: users };
  },
};

// Or destructure in the parameter list:
export const GET = {
  handler: async ({ db, req }) => {
    const user = await db('users').where('id', req.query.id).first();
    return { data: user };
  },
};

Queries

ctx.db is a standard Knex instance. All Knex query builder methods are available:

// Select with conditions
const activeUsers = await ctx.db('users')
  .where('active', true)
  .orderBy('created_at', 'desc')
  .limit(20);

// Join
const posts = await ctx.db('posts')
  .join('users', 'posts.author_id', 'users.id')
  .select('posts.*', 'users.name as author_name')
  .where('posts.published', true);

// Insert and get ID
const [id] = await ctx.db('users').insert({
  name: 'Alice',
  email: 'alice@example.com',
});

// Update
const count = await ctx.db('users')
  .where('id', id)
  .update({ name: 'Alice Smith' });

// Delete
await ctx.db('users').where('id', id).delete();

// Upsert (insert or update on conflict)
await ctx.db('users')
  .insert({ email: 'alice@example.com', name: 'Alice' })
  .onConflict('email')
  .merge();

// Raw SQL
const result = await ctx.db.raw('SELECT COUNT(*) as total FROM users WHERE active = ?', [true]);

// Aggregates
const { count } = await ctx.db('users').where('active', true).count('* as count').first();

Transactions

Use transactions when multiple queries must succeed or fail together:

export const POST = {
  handler: async (ctx) => {
    const { db } = ctx;
    const result = await db.transaction(async (trx) => {
      const [userId] = await trx('users').insert({
        name: ctx.req.body.name,
        email: ctx.req.body.email,
      });

      await trx('profiles').insert({
        user_id: userId,
        bio: '',
      });

      await trx('billing_accounts').insert({
        user_id: userId,
        plan: 'free',
      });

      return userId;
    });
    // If any query fails, all are rolled back

    return { status: 201, data: { id: result } };
  },
};

Migrations

Place Knex migration files in migrations/:

// migrations/20260101000000-create-users.js
export async function up(knex) {
  await knex.schema.createTable('users', (table) => {
    table.increments('id').primary();
    table.string('name').notNullable();
    table.string('email').notNullable().unique();
    table.boolean('active').defaultTo(true);
    table.timestamps(true, true);
  });
}

export async function down(knex) {
  await knex.schema.dropTableIfExists('users');
}

Use timestamp-based filenames (YYYYMMDDHHMM-description.js) to ensure correct ordering. Migrations run automatically during boot (both arcway dev and arcway start).

Common schema operations:

export async function up(knex) {
  // Add columns to existing table
  await knex.schema.alterTable('users', (table) => {
    table.string('avatar_url');
    table.integer('login_count').defaultTo(0);
    table.index('email'); // Add index
  });

  // Create a table with foreign keys
  await knex.schema.createTable('posts', (table) => {
    table.increments('id').primary();
    table.integer('author_id').unsigned().notNullable()
      .references('id').inTable('users').onDelete('CASCADE');
    table.string('title').notNullable();
    table.text('body');
    table.boolean('published').defaultTo(false);
    table.timestamps(true, true);
  });
}

Seeds

// seeds/001_seed_users.js
export default async function seed(db) {
  await db('users')
    .insert([
      { name: 'Alice', email: 'alice@example.com' },
      { name: 'Bob', email: 'bob@example.com' },
    ])
    .onConflict('email')
    .merge();
}

Run seeds with npx arcway seed.

Schema Introspection

Arcway can introspect the current database schema:

const schema = await ctx.db.introspectSchema();
// Returns table definitions with columns, types, and constraints

const markdown = ctx.db.generateSchemaMarkdown(schema);
// Generates markdown documentation of the schema

SQLite Optimizations

When using better-sqlite3, Arcway automatically applies these PRAGMAs after each connection:

PRAGMAValueEffect
journal_modeWALWrite-Ahead Logging — allows concurrent reads alongside a single writer, eliminates exclusive locks
busy_timeout5000Waits up to 5 seconds before returning a "database is locked" error, instead of failing immediately
synchronousNORMALFlushes at safe checkpoints rather than every write — significantly faster on high-latency storage (NAS, RAID) with minimal durability trade-off

These settings are applied unconditionally to all SQLite connections and are not configurable. They make SQLite safe and performant for concurrent API request handling. If you need PostgreSQL-level concurrency, switch to pg.

If you want SQLite to enforce foreign keys and ON DELETE CASCADE, enable:

database: {
  client: 'better-sqlite3',
  connection: { filename: '.build/db.sqlite3' },
  sqlite: {
    foreignKeys: true,
  },
}

Arcway will then run:

PRAGMA foreign_keys = ON;

Supported Databases

Configure in arcway.config.js:

export default {
  database: {
    client: 'sqlite3',                    // Default
    connection: { filename: '.build/db.sqlite3' },
    sqlite: {
      useNullAsDefault: true,             // SQLite only. Default: true
      foreignKeys: false,                 // SQLite only. Enables FK enforcement
    },
  },
  // Or PostgreSQL:
  database: {
    client: 'pg',
    connection: process.env.DATABASE_URL,
  },
  // Or MySQL:
  database: {
    client: 'mysql2',
    connection: {
      host: 'localhost',
      user: 'root',
      password: '',
      database: 'myapp',
    },
  },
};

On this page