Skip to content

Storage — dbportal

dbportal uses a pluggable storage abstraction layer that lets it operate in two modes:

  • Local mode (default): Zero-config, all data lives in the browser’s localStorage. Ideal for single-user / open-source usage.
  • Server mode: Data is persisted to a server-side database (SQLite or PostgreSQL) with per-user scoping. Ideal for teams and enterprise deployments.

Switching between modes requires only one environment variable — no code changes, no rebuild.

This document is split into two parts. Most readers want Part 1 — Setup & Configuration. For internals, see Part 2 — Architecture & Internals.


Part 1 — Setup & Configuration

Part 2 — Architecture & Internals


dbportal supports three storage modes. Pick the one that fits your use case and follow the steps below.

Mode Best For Persistence Multi-User Setup
Local (default) Solo dev, quick start Browser only No Zero config
SQLite Small teams, single server Server file Yes 1 env var
PostgreSQL Enterprise, multi-node External DB Yes 2 env vars

No configuration needed. All data stays in the browser’s localStorage.

Terminal window
# Just start the app — that's it
bun dev

What you get:

  • Instant start, no database required
  • Data persists across page reloads
  • Data is lost if browser storage is cleared or you switch browsers/devices

When to move on: When you need data to survive across devices, browsers, or team members.


A single file on the server. Great for self-hosted single-node deployments.

.env.local
STORAGE_PROVIDER=sqlite
Terminal window
bun dev

That’s it. When STORAGE_SQLITE_PATH is not provided, the default path is ./data/dbportal-storage.db (an existing ./data/libredb-storage.db from the snapshot is still used when the new file does not exist yet`.

On the first API request, the SQLite provider:

  1. Creates the directory — ./data/ (or whatever parent directory the path points to) is created recursively if it doesn’t exist
  2. Creates the database file — dbportal-storage.db is created by better-sqlite3
  3. Enables WAL mode — Write-Ahead Logging for better concurrent read performance
  4. Creates the table — user_storage table with the schema below

No manual setup, no migrations, no SQL scripts needed.

If you want the database file in a different location:

.env.local
STORAGE_PROVIDER=sqlite
STORAGE_SQLITE_PATH=/var/lib/libredb/storage.db

The directory must be writable by the app process. The directory and file are created automatically.

docker-compose.yml
services:
app:
image: ghcr.io/klinux/dbportal:latest
ports:
- "3000:3000"
environment:
- STORAGE_PROVIDER=sqlite
- STORAGE_SQLITE_PATH=/app/data/dbportal-storage.db
volumes:
- storage-data:/app/data
volumes:
storage-data:
Terminal window
docker-compose up -d

Volume is essential. Without it, data is lost when the container restarts.

Terminal window
curl http://localhost:3000/api/storage/config
# → {"provider":"sqlite","serverMode":true}

The table is auto-created, but if you prefer to create it yourself (e.g., for auditing or version control):

CREATE TABLE IF NOT EXISTS user_storage (
user_id TEXT NOT NULL,
collection TEXT NOT NULL,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, collection)
);
-- Recommended: enable WAL mode for concurrent read performance
PRAGMA journal_mode = WAL;

Recommended for production, teams, and high-availability deployments.

Important: Unlike SQLite, STORAGE_POSTGRES_URL is required. There is no default value. If you set STORAGE_PROVIDER=postgres without providing a connection string, the app will throw an error on the first storage request:

Error: STORAGE_POSTGRES_URL is required when STORAGE_PROVIDER=postgres
Terminal window
# Start a PostgreSQL instance (if you don't have one)
docker run --name libredb-pg \
-e POSTGRES_DB=libredb \
-e POSTGRES_USER=libredb \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
postgres:16-alpine
.env.local
STORAGE_PROVIDER=postgres
STORAGE_POSTGRES_URL=postgresql://libredb:secret@localhost:5432/libredb?sslmode=disable
Terminal window
bun dev

On the first API request, the PostgreSQL provider:

  1. Creates a connection pool — max 5 connections, 30s idle timeout
  2. Handles idle-client failures — the pool gets an error listener immediately (see below)
  3. Creates the table — user_storage table with the schema below via CREATE TABLE IF NOT EXISTS

The database itself must already exist. The table is auto-created, but the database is not.

This pool is long-lived: once created it serves every storage request for the life of the process. A pooled client that fails while idle — the server dropped it, the network went away, the DBA restarted the database — has no query to reject, so pg destroys it and emits error on the pool. Node treats an error event with no listener as an uncaught exception, so the provider attaches one that logs through the storage logger (provider=postgres) and lets the pool open a fresh client on the next request. A dropped idle connection therefore costs one log line, never the server process. The PostgreSQL database provider carries the same guard for the same reason (see providers/postgres.md).

The PostgreSQL user specified in STORAGE_POSTGRES_URL needs:

Privilege Why
CREATE TABLE Auto-create user_storage on first request (only needed once)
INSERT Save user data
UPDATE Update existing data
SELECT Read user data

If your DBA restricts CREATE TABLE, you can create the table manually (see below) and the user only needs INSERT/UPDATE/SELECT.

docker-compose.yml
services:
app:
image: ghcr.io/klinux/dbportal:latest
ports:
- "3000:3000"
environment:
- STORAGE_PROVIDER=postgres
- STORAGE_POSTGRES_URL=postgresql://libredb:secret@db:5432/libredb?sslmode=disable
depends_on:
db:
condition: service_healthy
db:
image: postgres:18
environment:
- POSTGRES_DB=libredb
- POSTGRES_USER=libredb
- POSTGRES_PASSWORD=secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libredb"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
Terminal window
docker-compose up -d

Just set the connection string — the table is auto-created:

Terminal window
STORAGE_PROVIDER=postgres
STORAGE_POSTGRES_URL=postgresql://user:pass@your-pg-host:5432/your_db

Use sslmode=disable for local/non-SSL PostgreSQL and sslmode=require for managed cloud PostgreSQL:

Terminal window
# Local PostgreSQL
STORAGE_POSTGRES_URL=postgresql://user:pass@localhost:5432/your_db?sslmode=disable
# Cloud PostgreSQL
STORAGE_POSTGRES_URL=postgresql://user:pass@your-pg-host:5432/your_db?sslmode=require
# Cloud PostgreSQL, with the server certificate actually verified
STORAGE_POSTGRES_URL=postgresql://user:pass@your-pg-host:5432/your_db?sslmode=verify-system

sslmode is read as libpq reads it (docs/CONTEXT.md §4.47): require (and prefer, allow, no-verify) encrypts without checking the chain - the mode for a managed database whose certificate is signed by its own CA, such as Cloud SQL - and verify-ca, verify-full and verify-system check it against the runtime’s trust store, which is what a publicly-signed certificate needs. This pool has no channel for a CA PEM. The URL reaches the pg driver without its sslmode: the driver reads the URL after the explicit setting and lets the URL win, and its own reading of require verifies - which is how a Cloud SQL store on the documented URL once failed with “unable to verify the first certificate”. verify-system is the connection form’s own mode name (SSLMode in src/lib/types.ts), kept for URLs written in its vocabulary.

Terminal window
curl http://localhost:3000/api/storage/config
# → {"provider":"postgres","serverMode":true}

The table is auto-created on first request. However, if you prefer to create it yourself — for example, in environments where the app user doesn’t have CREATE TABLE privileges, or you want to track schema changes in version control:

-- PostgreSQL
CREATE TABLE IF NOT EXISTS user_storage (
user_id TEXT NOT NULL,
collection TEXT NOT NULL,
data TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, collection)
);
-- Optional: index for faster lookups by user
CREATE INDEX IF NOT EXISTS idx_user_storage_user_id ON user_storage (user_id);

Minimal Privileges (When Table Already Exists)

Section titled “Minimal Privileges (When Table Already Exists)”

If a DBA creates the table, the app user only needs:

-- Grant only data access (no DDL needed)
GRANT SELECT, INSERT, UPDATE ON user_storage TO dbportal_app;

With STORAGE_PROVIDER=sqlite or postgres, every audit event the server emits (logins, denials, maintenance, every statement a person runs — see docs/CONTEXT.md §4.2) is also appended to an audit_events table next to user_storage:

Column Type Meaning
id text, primary key the event’s own id, minted once
ts timestamp when it happened
type text the event type, for filtering
data text the sanitized event as JSON — never a credential, never a raw error

On PostgreSQL the table is partitioned by range on ts (docs/CONTEXT.md §4.43): one partition per calendar month (audit_events_p2026_09) or, with AUDIT_PARTITION=week, per week from Monday (audit_events_w2026_09_14). The primary key is (ts, id), as a partitioned unique index must carry the partition key. The current period’s partition and the next two exist after every boot, a daily audit-partitions job (handed to the queue by the scheduler leader) keeps them ahead, and an append whose instant no partition holds makes one on the spot. Retention (AUDIT_RETENTION_DAYS) drops a partition whole once it is wholly past, instead of deleting rows. An install from before this has its plain table attached, with no copy, as the audit_events_legacy partition holding everything up to the end of the current period; that one partition is the only one whose old rows are deleted until it can go whole. Without a filter the admin page’s total is the planner’s estimate once the table holds more than a hundred thousand rows. SQLite keeps one table and prunes by row.

The table is append-only by contract: no code path updates or deletes a row, and it is not reachable through the per-user /api/storage routes. The admin Audit page reads it (GET /api/admin/audit answers source: "store"), so the record survives restarts and is the same across replicas. The stdout JSON line remains the authoritative channel; the write here is fire-and-forget, and a store that is down fails the event (logged) — never the request that produced it. On STORAGE_PROVIDER=local there is no table and the admin page reads the per-process ring buffer.


When you switch from local mode to SQLite or PostgreSQL, existing browser data is automatically migrated on first login:

  1. User opens the app in server mode
  2. The sync hook detects it’s the first time (no dbportal_server_migrated flag)
  3. All existing localStorage data is sent to the server via POST /api/storage/migrate (a fresh browser with no dbportal_* data simply sets the flag and skips the upload)
  4. Server upserts each collection as a whole JSON blob — one row per user per collection, replacing any existing row
  5. A flag is set in localStorage to prevent re-migration
  6. From this point on, the server is the source of truth

No manual steps required. Just change the env var and restart.

If multiple users were sharing a browser in local mode, only the data from the user who migrates first will be sent. Each user’s server storage is isolated by their login email.

For the full migration lifecycle and the underlying merge semantics, see Migration Flow in Part 2.


In sqlite and postgres modes, credentials are encrypted before they are written. There is nothing to switch on and nothing to configure.

Seven fields, all on a saved connection:

Field What it is
password The database password
connectionString A URL that can embed user:password@
agentPassword The optional least-privilege agent-profile password (#328)
ssl.clientKey The TLS client private key
sshTunnel.password The SSH password
sshTunnel.privateKey The SSH private key
sshTunnel.passphrase The passphrase that unlocks the key above

Everything else stays readable, deliberately: host, port, user, agentUser, database, name and the TLS certificates (ssl.caCert, ssl.clientCert — certificates are public by construction). An operator holding a dump has to be able to answer “which of my databases is in here”; that is incident response, not a leak. No other collection is touched — history and saved_queries hold SQL text, which is the product’s data rather than its secrets.

Each value is stored as v1:<iv>:<ciphertext> using AES-256-GCM with a fresh random IV, so two identical passwords do not look identical in the store, and a tampered value is detected rather than silently decrypting to something else.

STORAGE_ENCRYPTION_KEY Key used
unset (default) Derived from JWT_SECRET via HKDF-SHA256
set (min 32 characters) Derived from that value via HKDF-SHA256

Deriving from JWT_SECRET is what keeps the zero-config promise: no new required variable, and if you never set JWT_SECRET either, the first-run bootstrap generates and persists one (<data dir>/auth-bootstrap.json), so the key is stable across restarts.

Set a dedicated STORAGE_ENCRYPTION_KEY where key separation matters — most usefully so that rotating your session-signing secret does not also invalidate every saved connection password.

Rotating a key invalidates stored credentials

Section titled “Rotating a key invalidates stored credentials”

This is the trade-off, stated plainly: rotating whichever key is in use makes every stored credential unreadable. That applies to rotating JWT_SECRET when STORAGE_ENCRYPTION_KEY is unset, and to rotating STORAGE_ENCRYPTION_KEY when it is set. It also applies if you lose the bootstrap file — a container without a persistent volume for its data directory regenerates JWT_SECRET on every start.

What happens is bounded and recoverable:

  • The connection survives. Its name, host, port, user and database are still there.
  • The unreadable field is omitted, not replaced with garbage. The password box is empty.
  • A warning is written to the server log once per read: Stored connection secrets could not be decrypted: N field(s) were omitted.
  • Nothing is deleted from the database by the read itself.

To recover, restore the previous key and restart, before using the app. Reads happen on page load; the first time the app writes that collection back, the omitted values are gone for good, because the browser copy is the source the server is updated from. If you cannot restore the key, re-enter the affected passwords once and they are re-encrypted under the current key.

Reads accept both plaintext and encrypted values, and writes always produce encrypted ones. An existing store therefore migrates as it is used, with no migration command, no downtime and no version column. A row that is never written again stays plaintext — which is why the STORAGE_ENCRYPTION_KEY upgrade is safe to roll back.

  • Browser localStorage is not encrypted. It is the rendering source and it holds the same credentials in the clear. That is a deliberate product decision — it is what lets dbportal work without a master password — and it is why cross-site scripting is treated as a top-severity issue in this project rather than a session-theft issue.
  • Anyone who can read the server’s environment can read the credentials. The key lives there. This protects a stolen database file or dump on its own; it is not a vault.
  • For STORAGE_PROVIDER=sqlite with no STORAGE_ENCRYPTION_KEY set, a backup or volume snapshot is NOT protected. The fallback key derives from JWT_SECRET, persisted in <data dir>/auth-bootstrap.json — the same directory STORAGE_SQLITE_PATH puts the database file in, and the one directory the Helm chart mounts as a single /app/data volume. A snapshot of that volume carries the ciphertext and the key that opens it side by side. Set STORAGE_ENCRYPTION_KEY from outside that volume — a Kubernetes Secret, an environment variable your orchestrator supplies — to close this: once the key is not itself part of the backup, a snapshot is genuinely useless without it. STORAGE_PROVIDER=postgres does not share this exposure by default, because the key material lives in the app’s own filesystem, a volume separate from the database that gets backed up.
  • GET /api/storage returns credentials in plaintext to their authenticated owner. It has to: the app must be able to redisplay a saved password for editing.

Variable Required Default Description
STORAGE_PROVIDER No local local, sqlite, or postgres
STORAGE_SQLITE_PATH No ./data/dbportal-storage.db Path to SQLite file. Directory and file are auto-created.
STORAGE_POSTGRES_URL Yes (postgres mode) — PostgreSQL connection string. No default — app will error without it.

These are server-side only variables (no NEXT_PUBLIC_ prefix). The client discovers the mode at runtime via GET /api/storage/config. This means one Docker image works for all modes. See Why Not NEXT_PUBLIC_*? for the rationale.

Mode Config needed What’s auto-created
local Nothing N/A (browser localStorage)
sqlite Just STORAGE_PROVIDER=sqlite Directory + DB file + WAL mode + table
postgres STORAGE_PROVIDER=postgres + STORAGE_POSTGRES_URL Table only (database must exist)

Check if the storage backend is reachable:

Terminal window
# Storage mode info (always works, no auth needed)
curl http://localhost:3000/api/storage/config
# Full data fetch (requires auth cookie)
curl -b cookies.txt http://localhost:3000/api/storage

  1. Check storage mode: curl http://localhost:3000/api/storage/config
  2. Make sure the response shows "serverMode": true
  3. Check browser console for sync errors (look for [StorageSync] prefixed logs)
  • The directory in STORAGE_SQLITE_PATH must be writable by the app process
  • In Docker, make sure the volume is mounted correctly

PostgreSQL: “STORAGE_POSTGRES_URL is required”

Section titled “PostgreSQL: “STORAGE_POSTGRES_URL is required””
  • You set STORAGE_PROVIDER=postgres but didn’t provide STORAGE_POSTGRES_URL
  • Unlike SQLite, PostgreSQL has no default — a connection string is always required
  • Fix: add STORAGE_POSTGRES_URL=postgresql://user:pass@host:5432/dbname to your env
  • Verify STORAGE_POSTGRES_URL is correct and the database is reachable
  • In Docker Compose, use the service name (db) as the host, not localhost
  • Check that the PostgreSQL container is healthy: docker-compose ps

PostgreSQL: “server does not support SSL connections”

Section titled “PostgreSQL: “server does not support SSL connections””
  • Your PostgreSQL server does not accept SSL, but SSL is enabled in the connection URL
  • Fix local setups by adding ?sslmode=disable to STORAGE_POSTGRES_URL
  • For managed cloud PostgreSQL, use ?sslmode=require

“Data disappeared after switching modes”

Section titled ““Data disappeared after switching modes””
  • Switching from server mode back to local mode doesn’t pull data from the server
  • Local mode only reads from localStorage
  • To recover: switch back to server mode, the data is still in the database

“My connection passwords are blank after a restart”

Section titled ““My connection passwords are blank after a restart””

Look for this line in the server log:

Stored connection secrets could not be decrypted: 3 field(s) were omitted.

The encryption key changed. Either JWT_SECRET was rotated (and STORAGE_ENCRYPTION_KEY is not set), or STORAGE_ENCRYPTION_KEY itself changed, or the data directory holding auth-bootstrap.json was not persisted so a fresh JWT_SECRET was generated on start.

Restore the previous key and restart before using the app — see Rotating a key invalidates stored credentials. If the key is gone, re-enter the affected passwords; everything else about the connection is intact.

  • Migration runs once per browser (guarded by the dbportal_server_migrated flag) and replaces each collection wholesale, so duplicates shouldn’t occur
  • If you do see them, check whether the same user pushed data from multiple browsers before the first migration completed

Both SQLite and PostgreSQL use the same single-table design. The table is auto-created on first request, but the full DDL is provided here for reference.

CREATE TABLE IF NOT EXISTS user_storage (
user_id TEXT NOT NULL,
collection TEXT NOT NULL,
data TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, collection)
);
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS user_storage (
user_id TEXT NOT NULL,
collection TEXT NOT NULL,
data TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, collection)
);
-- Optional: index for faster lookups by user
CREATE INDEX IF NOT EXISTS idx_user_storage_user_id ON user_storage (user_id);
Column Type Description
user_id TEXT User’s email from JWT token (e.g., admin@libredb.org)
collection TEXT Data category: connections, history, saved_queries, schema_snapshots, saved_charts, active_connection_id, audit_log, masking_config, threshold_config, dismissed_seeds, ssh_profiles (owner shared:ssh-profiles only)
data TEXT JSON-serialized collection data
updated_at TEXT / TIMESTAMPTZ Last modification timestamp

Each row stores one user’s one collection as a JSON blob. Adding a new collection type requires no schema changes — just a new row.


This part describes the internals of the storage abstraction layer: design goals, module structure, the storage facade API, provider interface, the write-through cache, the useStorageSync hook lifecycle, migration flows, and the security model.

Goal Approach
Zero breaking changes All 16+ consumer components keep the same synchronous storage.* API
Zero-config default localStorage works out of the box — no database, no env vars needed
Single image, all modes Runtime config via env var, not build-time NEXT_PUBLIC_*
Per-user isolation Server storage scoped by JWT username — no cross-user leaks
Graceful degradation If server is unreachable, localStorage continues to work
Extensible Adding a new backend (e.g., MySQL, DynamoDB) requires one file implementing ServerStorageProvider

┌──────────────────────────────┐
│ 16+ Consumer Components │ ← Unchanged, same sync API
│ storage.getHistory() │
│ storage.addToHistory() │
└──────────────┬───────────────┘
│ sync read/write
┌──────────────▼───────────────┐
│ Storage Facade │ ← localStorage read/write + CustomEvent dispatch
│ src/lib/storage/ │
│ storage-facade.ts │
└──────────────┬───────────────┘
│ CustomEvent: 'libredb-storage-change'
┌──────────────▼───────────────┐
│ useStorageSync Hook │ ← Mounted in Studio.tsx (server mode only)
│ src/hooks/ │
│ use-storage-sync.ts │
└──────────────┬───────────────┘
│ fetch (debounced 500ms)
┌──────────────▼───────────────┐
│ API Routes │ ← JWT auth + user scoping
│ /api/storage/* │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ ServerStorageProvider │ ← Strategy Pattern
│ ┌─────────┐ ┌────────────┐ │
│ │ SQLite │ │ PostgreSQL │ │
│ └─────────┘ └────────────┘ │
└──────────────────────────────┘

Key insight: localStorage is always the rendering source (L1 cache). The server database is the persistent source of truth (L2). The sync hook keeps them in sync via a write-through cache pattern.


All application state is organized into 10 collections, each stored as a JSON blob:

Collection Type Description Max Items
connections DatabaseConnection[] Saved database connections —
history QueryHistoryItem[] Query execution history 500
saved_queries SavedQuery[] User-saved SQL/JSON queries —
schema_snapshots SchemaSnapshot[] Schema diff snapshots 50
saved_charts SavedChartConfig[] Saved chart configurations —
active_connection_id string | null Currently active connection —
audit_log AuditEvent[] Audit trail events 1000
masking_config MaskingConfig Data masking rules and RBAC —
ssh_profiles SshProfileRecord[] Shared SSH profiles (docs/CONTEXT.md §4.9); password, key and passphrase sealed like a tunnel’s —
threshold_config ThresholdConfig[] Monitoring alert thresholds —

A snapshot taken before the object model has no kind and no path. schema_snapshots holds what the schema list held when the snapshot was taken, and a live reading now always carries an object’s kind and its full address; a record written by an older version carries the name and the columns only. Those records are kept and stay comparable rather than being migrated or discarded, so the diff matches an old snapshot to a new reading BY NAME, and a diff against one cannot tell a view from a table or two same-named objects in different schemas apart. Take a fresh snapshot to get a baseline that carries both. Nothing is lost either way: a stored snapshot is the user’s own data and this product does not rewrite it in place.

Both SQLite and PostgreSQL use the same logical schema — a single table with collection-based JSON blobs:

CREATE TABLE IF NOT EXISTS user_storage (
user_id TEXT NOT NULL, -- JWT username (email)
collection TEXT NOT NULL, -- 'connections', 'history', etc.
data TEXT NOT NULL, -- JSON serialized
updated_at TEXT/TIMESTAMPTZ NOT NULL, -- Last modification time
PRIMARY KEY (user_id, collection)
);

This design is intentionally simple:

  • No schema migrations needed when adding new collections
  • One row per user per collection — efficient upsert
  • JSON blobs keep the server storage schema-agnostic

The full per-dialect DDL (with defaults and the recommended index) is in Database Schema Reference in Part 1.

Each collection maps to a dbportal_-prefixed localStorage key:

connections → dbportal_connections
history → dbportal_history
saved_queries → dbportal_saved_queries
schema_snapshots → dbportal_schema_snapshots
saved_charts → dbportal_saved_charts
active_connection_id → dbportal_active_connection_id
audit_log → dbportal_audit_log
masking_config → dbportal_masking_config
threshold_config → dbportal_threshold_config

src/lib/storage/
├── index.ts # Barrel export — preserves @/lib/storage import path
├── types.ts # StorageData, StorageCollection, ServerStorageProvider
├── local-storage.ts # Pure localStorage CRUD (SSR-safe)
├── storage-facade.ts # Public storage object with domain methods
├── factory.ts # Env-based provider instantiation (singleton)
└── providers/
├── sqlite.ts # better-sqlite3 implementation
└── postgres.ts # pg (Pool) implementation
src/hooks/
└── use-storage-sync.ts # Write-through cache hook
src/app/api/storage/
├── config/route.ts # GET: storage mode discovery (public)
├── route.ts # GET: fetch all user data (auth required)
├── [collection]/route.ts # PUT: update single collection (auth required)
└── migrate/route.ts # POST: localStorage → server migration (auth required)

File: src/lib/storage/local-storage.ts

Pure, side-effect-free localStorage CRUD with SSR safety:

// All operations check isClient() before accessing localStorage
export function readJSON<T>(collection: string): T | null;
export function writeJSON(collection: string, data: unknown): boolean; // false on failure (e.g. QuotaExceededError)
export function readString(collection: string): string | null;
export function writeString(collection: string, value: string): boolean; // false on failure
export function remove(collection: string): void;
export function getKey(collection: string): string; // → 'dbportal_' + collection
  • Every function is guarded by isClient() — safe to call during SSR (returns null / no-op)
  • JSON parse failures return null instead of throwing

File: src/lib/storage/storage-facade.ts

The public storage object provides the same synchronous API that all 16+ consumer components use. Every mutation method:

  1. Writes to localStorage (immediate)
  2. Dispatches a CustomEvent('libredb-storage-change') with the collection name and data
// Example: saving a connection
storage.saveConnection(conn);
// 1. Reads existing connections from localStorage
// 2. Upserts by ID
// 3. Writes back to localStorage
// 4. Dispatches CustomEvent({ collection: 'connections', data: updatedList })
Category Methods
History getHistory(), addToHistory(item), clearHistory()
Saved Queries getSavedQueries(), saveQuery(query), deleteSavedQuery(id)
Schema Snapshots getSchemaSnapshots(connId?), saveSchemaSnapshot(snap), deleteSchemaSnapshot(id)
Charts getSavedCharts(), saveChart(chart), deleteChart(id)
Active Connection getActiveConnectionId(), setActiveConnectionId(id)
Audit Log getAuditLog(), saveAuditLog(events)
Masking Config getMaskingConfig(), saveMaskingConfig(config)
Threshold Config getThresholdConfig(), saveThresholdConfig(thresholds)

All read methods are synchronous — they read from localStorage only. No network calls.


File: src/lib/storage/types.ts

interface ServerStorageProvider {
initialize(): Promise<void>;
getAllData(userId: string): Promise<Partial<StorageData>>;
getCollection<K extends StorageCollection>(
userId: string, collection: K
): Promise<StorageData[K] | null>;
setCollection<K extends StorageCollection>(
userId: string, collection: K, data: StorageData[K]
): Promise<void>;
mergeData(userId: string, data: Partial<StorageData>): Promise<void>;
isHealthy(): Promise<boolean>;
close(): Promise<void>;
}

File: src/lib/storage/providers/sqlite.ts Package: better-sqlite3 (Node.js compatible, not bun:sqlite)

Feature Detail
WAL mode Enabled for concurrent read performance
Auto-create Directory and database file created on initialize()
Upsert INSERT ... ON CONFLICT (user_id, collection) DO UPDATE
Transactions mergeData() wraps all inserts in a single transaction
Health check SELECT 1 AS ok
STORAGE_PROVIDER=sqlite
STORAGE_SQLITE_PATH=./data/dbportal-storage.db # default

File: src/lib/storage/providers/postgres.ts Package: pg (connection pool)

Feature Detail
Pool config max: 5, idleTimeoutMillis: 30000
SSL behavior sslmode=disable for local/non-SSL servers, sslmode=require for cloud servers
Upsert INSERT ... ON CONFLICT (user_id, collection) DO UPDATE
Transactions mergeData() uses BEGIN/COMMIT/ROLLBACK with client checkout
Health check SELECT 1 AS ok
STORAGE_PROVIDER=postgres
STORAGE_POSTGRES_URL=postgresql://user:pass@localhost:5432/libredb?sslmode=disable

File: src/lib/storage/factory.ts

The factory uses the Singleton pattern — one provider instance per process, lazy-initialized on first access:

getStorageProviderType() // → 'local' | 'sqlite' | 'postgres'
isServerStorageEnabled() // → true if not 'local'
getStorageConfig() // → { provider, serverMode }
getStorageProvider() // → ServerStorageProvider | null (singleton)
closeStorageProvider() // → cleanup for testing

Provider classes are dynamically imported — SQLite and PostgreSQL dependencies are only loaded when their provider is selected.


All routes (except /config) require JWT authentication. The authenticated user’s username (email) is used as the user_id for storage scoping.

Endpoint Method Auth Purpose
/api/storage/config GET Public Runtime storage mode discovery
/api/storage GET JWT Fetch all collections for the authenticated user
/api/storage/[collection] PUT JWT Update a single collection
/api/storage/migrate POST JWT Merge localStorage dump into server storage

GET /api/storage/config

{ "provider": "sqlite", "serverMode": true }

GET /api/storage

{
"connections": [{ "id": "c1", "name": "Prod DB", ... }],
"history": [{ "id": "h1", "query": "SELECT ...", ... }],
...
}

PUT /api/storage/connections

// Request: { "data": [{ "id": "c1", "name": "Prod DB", ... }] }
// Response: { "ok": true }

POST /api/storage/migrate

// Request: { "connections": [...], "history": [...], ... }
// Response: { "ok": true, "migrated": ["connections", "history"] }

When STORAGE_PROVIDER=local, all data routes return 404 Not Found (config route always works).


File: src/hooks/use-storage-sync.ts

The hook is mounted in Studio.tsx after useAuth() and orchestrates all client-server synchronization.

interface StorageSyncState {
isServerMode: boolean; // Server storage active?
isSyncing: boolean; // Currently transferring data?
isReady: boolean; // Init complete (config fetched + initial pull done)?
lastSyncedAt: Date | null; // Last successful sync timestamp
syncError: string | null; // Last error message (null = healthy)
}
App Mount
│
├─ GET /api/storage/config
│ ├─ serverMode: false → done (localStorage only)
│ └─ serverMode: true ──┐
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Check dbportal_server_migrated flag │
│ │ ├─ Not migrated → POST /api/storage/migrate│
│ │ │ (send all localStorage → server merge) │
│ │ │ Set flag in localStorage │
│ │ └─ Already migrated → skip │
│ └──────────────────────┬──────────────────────┘
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Pull: GET /api/storage │
│ │ → Write server data into localStorage │
│ │ → Components re-render from localStorage │
│ └──────────────────────┬──────────────────────┘
│ │
│ ┌──────────────────────▼──────────────────────┐
│ │ Listen: 'libredb-storage-change' events │
│ │ → Collect pending collections │
│ │ → Debounce 500ms │
│ │ → PUT /api/storage/[collection] for each │
│ └─────────────────────────────────────────────┘
│
▼ (ongoing)

When any storage.* mutation fires:

  1. Facade writes to localStorage (immediate, synchronous)
  2. Facade dispatches CustomEvent('libredb-storage-change', { collection, data })
  3. Hook captures event, adds collection to pending set
  4. After 500ms of no new mutations, hook flushes:
    • Reads each pending collection from localStorage
    • Sends PUT /api/storage/[collection] for each
  • If /api/storage/config fails → stays in localStorage-only mode
  • If push fails → logs warning, sets syncError, does not block the UI
  • Components always read from localStorage — no loading states for storage

When a user first enables server mode (or a new user logs in for the first time):

1. Hook detects serverMode = true
2. Checks localStorage('dbportal_server_migrated') flag
3. If not migrated:
a. Reads whichever of the 10 collections exist in localStorage (a fresh browser with none simply sets the flag and skips)
b. POST /api/storage/migrate with the collected payload
c. Server calls provider.mergeData() — upserts each collection as a whole blob in one transaction
d. Sets 'dbportal_server_migrated' flag in localStorage
4. Pull: GET /api/storage → overwrite localStorage with server data
5. Subsequent mutations sync normally via push

This ensures existing localStorage data is preserved when transitioning to server mode.

For the operator-facing summary of this behavior, see Migration: Local to Server in Part 1.


Variable Default Required Description
STORAGE_PROVIDER local No Storage backend: local, sqlite, or postgres
STORAGE_SQLITE_PATH ./data/dbportal-storage.db No Path to SQLite database file
STORAGE_POSTGRES_URL — If postgres PostgreSQL connection string (sslmode=disable local, sslmode=require cloud)

Next.js NEXT_PUBLIC_* variables are inlined at build time as static strings. This means:

  • Every storage mode would require a separate Docker build
  • Cannot change storage mode without rebuilding

Instead, the client discovers the storage mode at runtime via GET /api/storage/config. One Docker image supports all modes.


Every row in user_storage is scoped by user_id:

(admin@libredb.org, connections) → [{"id":"c1", "name":"Prod DB"...}]
(admin@libredb.org, history) → [{"id":"h1", "query":"SELECT..."...}]
(user@libredb.org, connections) → [{"id":"c2", "name":"Dev DB"...}]
  • user_id = JWT session username (email address)
  • Client never sends user_id — server always extracts from JWT cookie
  • Every query includes WHERE user_id = $username — no cross-user access possible
  • /api/storage/config is public — returns only { provider, serverMode }, no sensitive data
  • All other /api/storage/* routes require a valid JWT session via getSession()
  • Unauthorized requests receive 401 Unauthorized

OIDC users (Auth0, Keycloak, Okta, Azure AD) have their preferred_username or email claim mapped to the same username field used as user_id. See OIDC.md for SSO configuration — it pairs well with server storage for team deployments.


docker-compose.yml
services:
libredb-studio:
environment:
STORAGE_PROVIDER: sqlite
STORAGE_SQLITE_PATH: /app/data/dbportal-storage.db
volumes:
- storage-data:/app/data
volumes:
storage-data:

The Dockerfile includes better-sqlite3 native bindings and creates the /app/data directory.

services:
libredb-studio:
environment:
STORAGE_PROVIDER: postgres
STORAGE_POSTGRES_URL: postgresql://user:pass@db:5432/libredb
depends_on:
- db
db:
image: postgres:18
environment:
POSTGRES_DB: libredb
POSTGRES_USER: user
POSTGRES_PASSWORD: pass

No volume needed on the app container — data lives in PostgreSQL.

For step-by-step operator instructions (including healthchecks and verification), see Docker under SQLite and Docker Compose (App + PostgreSQL) in Part 1.


To add a new storage backend (e.g., MySQL, DynamoDB):

Create src/lib/storage/providers/your-provider.ts:

import type { ServerStorageProvider, StorageData, StorageCollection } from '../types';
export class YourStorageProvider implements ServerStorageProvider {
async initialize(): Promise<void> { /* create table */ }
async getAllData(userId: string): Promise<Partial<StorageData>> { /* ... */ }
async getCollection<K extends StorageCollection>(
userId: string, collection: K
): Promise<StorageData[K] | null> { /* ... */ }
async setCollection<K extends StorageCollection>(
userId: string, collection: K, data: StorageData[K]
): Promise<void> { /* upsert */ }
async mergeData(
userId: string, data: Partial<StorageData>
): Promise<void> { /* batch upsert in transaction */ }
async isHealthy(): Promise<boolean> { /* SELECT 1 */ }
async close(): Promise<void> { /* cleanup */ }
}

Update src/lib/storage/factory.ts:

// Add to StorageProviderType
type StorageProviderType = 'local' | 'sqlite' | 'postgres' | 'your-provider';
// Add dynamic import in getStorageProvider()
case 'your-provider': {
const { YourStorageProvider } = await import('./providers/your-provider');
instance = new YourStorageProvider(process.env.STORAGE_YOUR_URL!);
break;
}

Create tests/unit/lib/storage/providers/your-provider.test.ts with mocked driver.

That’s it — no changes needed to the facade, API routes, sync hook, or any consumer components.


  • ARCHITECTURE.md — Overall system architecture
  • OIDC.md — SSO configuration (pairs well with server storage for team deployments)