WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

SQLite Support Design#

Date: 2026-02-24 Status: Approved, pending implementation plan

Motivation#

Adding SQLite as a fully supported database backend alongside PostgreSQL serves two audiences:

  • Self-hosters who want to run atBB on a single server without operating a separate PostgreSQL service
  • Developers and agents who want to spin up, test, and discard a local database without any external infrastructure

The implementation is transparent to all application code above the createDb factory — route handlers, middleware, and services remain unaware of which database they are talking to.

Scope#

SQLite is a full production deployment target, not only a development convenience. Both PostgreSQL and SQLite are equally supported. Operators choose their backend at deploy time via DATABASE_URL.

Architecture Overview#

The URL prefix in DATABASE_URL is the single decision point. Everything below createDb is dialect-specific; everything above it is identical for both backends.

DATABASE_URL=postgres://...        →  postgres.js driver  →  Drizzle (PostgreSQL dialect)
DATABASE_URL=file:./data/atbb.db   →  @libsql/client      →  Drizzle (LibSQL dialect)
DATABASE_URL=file::memory:         →  @libsql/client      →  Drizzle (LibSQL in-memory, tests)
DATABASE_URL=libsql://...          →  @libsql/client      →  Drizzle (Turso cloud, no code changes)

File layout changes#

packages/db/
  src/
    schema.ts              ← PostgreSQL schema (updated: permissions column removed)
    schema.sqlite.ts       ← SQLite schema (new: identical structure, dialect-specific column types)
    index.ts               ← createDb() with URL-prefix detection (updated)

apps/appview/
  drizzle/                 ← PostgreSQL migrations (existing + two new migrations)
  drizzle-sqlite/          ← SQLite migrations (new, single clean initial migration)
  drizzle.postgres.config.ts   ← renamed from drizzle.config.ts
  drizzle.sqlite.config.ts     ← new
  scripts/
    migrate-permissions.ts ← one-time data migration script (new)

Schema Changes#

New role_permissions join table#

The permissions text[] array column in roles is replaced by a dedicated role_permissions table. This is a data-model improvement independent of SQLite support: it adds referential integrity (cascade delete when a role is removed) and makes "find all roles with permission X" queries natural.

Both schema files (schema.ts and schema.sqlite.ts) define this table. The structure is identical; only the Drizzle column helper syntax differs by dialect.

// Postgres (schema.ts)
export const rolePermissions = pgTable("role_permissions", {
  roleId: bigint("role_id", { mode: "bigint" })
    .notNull()
    .references(() => roles.id, { onDelete: "cascade" }),
  permission: text("permission").notNull(),
}, (t) => [
  primaryKey({ columns: [t.roleId, t.permission] }),
]);

// SQLite (schema.sqlite.ts)
export const rolePermissions = sqliteTable("role_permissions", {
  roleId: integer("role_id", { mode: "bigint" })
    .notNull()
    .references(() => roles.id, { onDelete: "cascade" }),
  permission: text("permission").notNull(),
}, (t) => [
  primaryKey({ columns: [t.roleId, t.permission] }),
]);

The permissions column is removed from roles in both schema files.

Dialect differences between the two schema files#

Concern PostgreSQL (schema.ts) SQLite (schema.sqlite.ts)
Import drizzle-orm/pg-core drizzle-orm/sqlite-core
Auto-increment IDs bigserial integer({ mode: "bigint" }).primaryKey({ autoIncrement: true })
Timestamps timestamp({ withTimezone: true, mode: "date" }) integer({ mode: "timestamp" })
Permissions (removed) (removed)

Both dialects use mode: "bigint" for integer IDs and mode: "date" / mode: "timestamp" for dates, so TypeScript types are identical (bigint for IDs, Date for timestamps). The Database union type works without narrowing in route handlers.

Impact on checkPermission#

The current role.permissions.includes(permission) pattern is replaced by a targeted existence query — more efficient since it avoids fetching all permissions to check one:

const [match] = await ctx.db
  .select()
  .from(rolePermissions)
  .where(and(
    eq(rolePermissions.roleId, role.id),
    or(
      eq(rolePermissions.permission, permission),
      eq(rolePermissions.permission, "*")
    )
  ))
  .limit(1);

return !!match; // fail-closed: undefined → false

All other permission-related queries (listing a role's permissions, seeding default roles) are updated to use join or relational queries against role_permissions.

createDb Factory#

packages/db/src/index.ts becomes:

import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import { drizzle as drizzleSqlite } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import postgres from "postgres";
import * as pgSchema from "./schema.js";
import * as sqliteSchema from "./schema.sqlite.js";

export function createDb(databaseUrl: string) {
  if (databaseUrl.startsWith("postgres")) {
    return drizzlePg(postgres(databaseUrl), { schema: pgSchema });
  }
  return drizzleSqlite(
    createClient({ url: databaseUrl }),
    { schema: sqliteSchema }
  );
}

export type Database = ReturnType<typeof createDb>;
export type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0];
export type DbOrTransaction = Database | Transaction;

AppContext is unchanged — db: Database continues to work for both backends.

Drizzle Configuration#

The existing apps/appview/drizzle.config.ts is renamed and a sibling added:

// drizzle.postgres.config.ts (renamed from drizzle.config.ts)
export default defineConfig({
  schema: "../../packages/db/src/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: { url: process.env.DATABASE_URL! },
});

// drizzle.sqlite.config.ts (new)
export default defineConfig({
  schema: "../../packages/db/src/schema.sqlite.ts",
  out: "./drizzle-sqlite",
  dialect: "sqlite",
  dbCredentials: { url: process.env.DATABASE_URL! },
});

apps/appview/package.json gains dialect-scoped scripts:

"db:generate":        "drizzle-kit generate --config=drizzle.postgres.config.ts",
"db:migrate":         "drizzle-kit migrate  --config=drizzle.postgres.config.ts",
"db:generate:sqlite": "drizzle-kit generate --config=drizzle.sqlite.config.ts",
"db:migrate:sqlite":  "drizzle-kit migrate  --config=drizzle.sqlite.config.ts"

Migration Strategy#

PostgreSQL (existing deployments)#

The permissions text[] column cannot be dropped atomically with data preservation. The migration is split into three ordered steps:

Step 1 — Schema migration: add role_permissions table

apps/appview/drizzle/0011_add_role_permissions.sql
  CREATE TABLE role_permissions (role_id bigint, permission text, PRIMARY KEY (role_id, permission))
  -- permissions column is NOT dropped here

Step 2 — Data migration script (must run between steps 1 and 3)

apps/appview/scripts/migrate-permissions.ts
  SELECT id, permissions FROM roles WHERE permissions != '{}'
  → INSERT INTO role_permissions (role_id, permission) VALUES (...)
     ON CONFLICT DO NOTHING   ← idempotent, safe to re-run
  → Prints: "Migrated N permissions across M roles"

Step 3 — Schema migration: drop the now-redundant column

apps/appview/drizzle/0012_drop_permissions_column.sql
  ALTER TABLE roles DROP COLUMN permissions

SQLite (fresh deployments only)#

SQLite deployments always start clean. The drizzle-sqlite/ folder contains a single initial migration that includes role_permissions from the start and has no permissions column. No data migration script is needed for SQLite.

Docker Deployment#

The existing docker-compose.yml (PostgreSQL) is unchanged. A new compose file enables SQLite deployments without a database service:

# docker-compose.sqlite.yml
services:
  appview:
    build: .
    environment:
      DATABASE_URL: file:/data/atbb.db
    volumes:
      - atbb_data:/data
    ports:
      - "80:80"

volumes:
  atbb_data:

The Dockerfile needs no changes — the runtime binary is identical for both backends.

NixOS Module Changes (nix/module.nix)#

A new database.type option is added alongside the existing database.enable:

database = {
  enable = mkOption {
    type = types.bool;
    default = cfg.database.type == "postgresql";
    description = "Enable local PostgreSQL service (ignored for SQLite).";
  };
  type = mkOption {
    type = types.enum [ "postgresql" "sqlite" ];
    default = "postgresql";
    description = "Database backend. Use 'sqlite' for embedded single-file storage.";
  };
  name = mkOption { ... };  # unchanged, PostgreSQL only
  path = mkOption {
    type = types.path;
    default = "/var/lib/atbb/atbb.db";
    description = "Path to SQLite database file. Only used when database.type = 'sqlite'.";
  };
};

The atbb-appview service environment becomes conditional:

environment = {
  DATABASE_URL = if cfg.database.type == "sqlite"
    then "file:${cfg.database.path}"
    else "postgres:///atbb?host=/run/postgresql";
  # ...other env vars unchanged
};
serviceConfig = {
  StateDirectory = mkIf (cfg.database.type == "sqlite") "atbb"; # creates /var/lib/atbb/
  # ...
};

The atbb-migrate oneshot service runs the appropriate migration script:

script = if cfg.database.type == "sqlite"
  then "${atbb}/bin/atbb db:migrate:sqlite"
  else "${atbb}/bin/atbb db:migrate";

The PostgreSQL system service is conditionally enabled:

services.postgresql = mkIf (cfg.database.type == "postgresql" && cfg.database.enable) {
  # existing config unchanged
};

Existing module users default to database.type = "postgresql", so no existing NixOS configurations require changes.

Nix Package Changes (nix/package.nix)#

The drizzle-sqlite/ migrations directory must be included in the package alongside the existing drizzle/ directory:

# In installPhase, alongside existing drizzle copy:
cp -r apps/appview/drizzle      $out/apps/appview/drizzle
cp -r apps/appview/drizzle-sqlite $out/apps/appview/drizzle-sqlite  # new

devenv Changes (devenv.nix)#

The PostgreSQL service is made optional via a devenv input flag, allowing developers using SQLite to skip the database service entirely:

{ config, lib, pkgs, ... }: {
  # Postgres service — disable if using SQLite locally
  services.postgres = {
    enable = lib.mkDefault true;
    # ...existing config unchanged
  };
}

Developers opting into SQLite locally set in their .env:

DATABASE_URL=file:./data/atbb.db

and can disable the devenv Postgres service by overriding services.postgres.enable = false in a local devenv.local.nix (devenv supports this pattern via devenv.local.nix overrides).

Tests#

The test context detects the URL and selects the appropriate setup:

// test-context.ts
if (config.databaseUrl.startsWith("postgres")) {
  // existing postgres.js path — unchanged
} else {
  // SQLite: run migrations programmatically (no external process needed)
  const { migrate } = await import("drizzle-orm/libsql/migrator");
  await migrate(db, { migrationsFolder: resolvedDrizzleSqlitePath });
  // No manual cleanup needed — in-memory DB is discarded with the process
}

Developers without a running Postgres instance run the full test suite with:

DATABASE_URL=file::memory: pnpm test

CI continues to use PostgreSQL via DATABASE_URL=postgres://....


Operator Migration Instructions#

Fresh SQLite installs (new deployments)#

No special steps required. Run the standard migration command:

DATABASE_URL=file:./data/atbb.db pnpm --filter @atbb/appview db:migrate:sqlite

Or if using the NixOS module with database.type = "sqlite", the atbb-migrate service handles this automatically on startup.

Existing PostgreSQL deployments upgrading to role_permissions#

Warning: Steps must be run in order. Running step 3 before step 2 will permanently destroy all role permission data.

# Step 1: Add role_permissions table (does NOT drop permissions column yet)
pnpm --filter @atbb/appview db:migrate

# Step 2: Copy existing permission data into the new table
#         MUST run before step 3. Safe to re-run if interrupted.
pnpm --filter @atbb/appview migrate-permissions
# Verify output: "Migrated N permissions across M roles"
# Do not proceed to step 3 if N is unexpectedly 0 and you had role permissions.

# Step 3: Drop the now-redundant permissions column
pnpm --filter @atbb/appview db:migrate

Using the NixOS module: If autoMigrate is enabled, steps 1 and 3 run automatically on service restart. You must run step 2 manually between the two restarts:

# 1. Deploy the new build (runs step 1 automatically on atbb-migrate)
# 2. SSH into the server and run the data migration:
sudo -u atbb pnpm --filter @atbb/appview migrate-permissions
# 3. Restart the service (runs step 3 automatically on atbb-migrate)
sudo systemctl restart atbb-appview

Switching an existing PostgreSQL deployment to SQLite#

This requires a full data export and import — there is no automated migration path between backends. Export your data first using the atBB backup tooling (to be documented separately), then set up a fresh SQLite deployment and import. This is an unusual operation and only makes sense for very small deployments.