Database overview and operations
This section is the complete reference for Aivory v2.4.7 and the post-release main branch's relational data model: 60 tables, including the current domain-enrollment and passkey tables. The authoritative schema is embedded in the Go binary:
- SQLite (personal/dev):
server/internal/store/schema.sql(1,120 lines,//go:embed,store.go:22-23) - PostgreSQL (full edition):
server/internal/store/schema_pg.sql(1,048 lines,store.go:25-26)
Both files declare the same 60 tables (identical column names and semantics; only dialect types differ), applied idempotently at startup by store.Migrate(). The final effective schema = embedded CREATE TABLE + additive ALTER TABLE ADD COLUMN statements in store.Migrate (a few columns exist only in ALTERs — e.g. users.group_expires_at, messages.feedback, conversations.workspace_id; they are marked ※ ALTER-added in the text).
server/migrations/0001_init.sql is an early initial snapshot. No runtime code references it and it does not reflect the current structure. The only source of truth is the two schema*.sql files plus the additive migration steps in store.go.
Page navigation
| Page | Tables | Contents |
|---|---|---|
| Users & authentication | 6 | users, refresh_tokens, login_histories, oauth_providers, oauth_identities, passkeys |
| Channels, models & usage | 6 | channels, models, model_tags, model_skills, usage_logs, usage_stats |
| Conversations & messages | 9 | projects, conversations, messages, message_feedback, user_feedback, conversation_shares, memories, two conversation lease tables |
| Files & knowledge | 7 | files, knowledge_bases, knowledge_base_shares, documents, chunks, vector_points, artifacts |
| Subscriptions, credits & payments | 15 | user_groups, credit/quota ledgers, packages, redeem codes, payment channels/methods/orders/events |
| Workspaces & members | 8 | workspaces, members, invites, policies, per-library permissions, audit log, registration_domains, domain_users |
| Capabilities & integrations | 7 | skills, prompts, private copies user_*, mcp_servers, user_mcp_servers, image_styles |
| System & audit | 2 | settings (global key/value), pending_storage_cleanup (delete-purge queue) |
Totals: 6+6+9+7+15+8+7+2 = 60 tables. Aivory has no schema_migrations-style bookkeeping table (see migration mechanism below), so there is nothing else to place.
Data-model overview
The diagram groups the tables by domain and marks the main foreign-key directions; per-table column detail lives on the pages above.
Where data lives in each edition
Aivory ships a single Go backend binary; the storage engine is chosen at boot by DATABASE_URL / REDIS_URL / QDRANT_URL (see Deployment and environment). No table is personal-edition-only — all 60 tables are created in both editions; only vectors, cache, and the queue differ in engine:
| Data category | Personal (SQLite) | Full (PostgreSQL + Redis + Qdrant) |
|---|---|---|
| All 60 relational tables | single file DATA_DIR/aivory.db, WAL (DATABASE_URL=/app/data/aivory.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000), deploy/docker-compose.personal.yml:28) | named volume pgdata of the postgres container (postgres:16-alpine) |
| RAG vectors | vector_points table (embedded exact cosine search, VECTOR_BACKEND=sqlite pinned) | Qdrant (qdrant/qdrant:v1.12.4, volume qdrantdata), one collection per dimension named aivory_c<dim> (server/internal/vector/qdrant.go); vector_points stays empty |
| Rate-limit counters, request-signature nonces, stop/ban/config pub/sub, SSE replay buffers | in-process memory (single container) | Redis 7 (volume redisdata, --appendonly yes) |
| RAG ingest async queue | in-process queue | asynq (on Redis) |
| Uploads, artifacts, local object storage, backup ZIPs | directories created under DATA_DIR: uploads/ (local object storage under uploads/object-storage/), artifacts/, backups/ (bind mount) | same DATA_DIR (bind-mounted into app); admins may switch to S3/OSS |
| Admin runtime config (model policy, RAG knobs, quotas, moderation, announcement — full key list in System & audit) | settings table (DB key/value, hot reload) | same; across replicas via Redis channel cfg:invalidate |
Key points:
vector_pointsexists in both dialects and is genuinely written by the personal edition — deliberately kept, so logical backups (pg_dump/.backup) capture every relational fact without depending on a vector engine (schema.sql:793-795). Qdrant data is its "other copy of the truth" and must be backed up separately (see backup scope).- The personal compose file explicitly clears
REDIS_URL=""andQDRANT_URL=""so a copied env value can never silently re-enable external stores. - SQLite is single-writer:
store.OpenpinsSetMaxOpenConns(1)for SQLite and runsPRAGMA foreign_keys = ONper connection; PostgreSQL uses a pool (max open 20 / idle 10 / idle 5 min / lifetime 1 h,store.go:44-50,73-76). Hence the personal edition is single-instance only — never NFS-share the data dir or scale horizontally.
Naming and type conventions
Keep these in mind before reading any table. Pages use the notation SQLite type, writing INTEGER → BIGINT (SQLite → PostgreSQL) where dialects differ:
| Convention | Notes |
|---|---|
| Text primary keys | Most tables use id TEXT PRIMARY KEY shaped as prefix_ + 12 hex chars (48 bits of randomness, genID, store/ids.go:14-19), e.g. u_a1b2c3d4e5f6, oa_0f1e2d3c4b5a, msg_9e8d7c6b5a43, kb_1a2b3c4d5e6f, qr_7f6e5d4c3b2a. Exceptions: usage_logs.id (autoincrement integer), usage_stats.source_log_id (integer); the 17 tables with no id column use composite or alternative single-column primary keys (model_skills, model_group_quotas, workspace_members, workspace_policies, workspace_kb_member_permissions, oauth_identities, knowledge_base_shares, vector_points, refresh_tokens, settings, usage_stats, pending_storage_cleanup, payment_order_attempts, conversation_compaction_leases, conversation_generation_leases, registration_domains, domain_users) |
| High-entropy tokens | Where an id doubles as an unguessable capability secret (public shares conversation_shares.id, workspaces.invite_token, workspace_invites.token) the value comes from genToken(): 48 hex chars = 192 bits (ids.go:25-30) |
| Time | Unix seconds integers. SQLite INTEGER DEFAULT (strftime('%s','now')); PG uses BIGINT DEFAULT (extract(epoch from now())::bigint) (avoids 2038 and large token sums). now() below means this dialect default expression |
| Boolean flags | INTEGER 0/1 (common examples: enabled, pinned, fast). The PG dialect deliberately keeps INTEGER instead of BOOLEAN because the Go store layer reads/writes them via int (schema_pg.sql:9-11) |
| JSON | Stored in TEXT columns (e.g. blocks, kb_ids, features, permissions, settings.value); PG keeps TEXT too, so the store layer is dialect-independent. Only exceptions: message_feedback.reasons is JSONB in PG (TEXT in SQLite) and vector_points.embedding / user_feedback.screenshot are BYTEA (SQLite BLOB) |
| Empty string as "none" | Optional references use '' rather than NULL to mean "unlinked" (model_id, conversation_id, fallback_channel_id); workspace_id='' always means personal data |
| REAL → DOUBLE PRECISION | Billing amount columns (cost, credits, price_*, credit_ledger.amount, payment_orders.credits) upgrade to DOUBLE PRECISION in PG. Note: several display/limit mirror columns stay REAL in PG (PG's REAL is a 4-byte float): users.credits_permanent, user_groups.credit_allowance, credit_packages.credits, redeem_codes.credits, redeem_redemptions.credits, model_group_quotas.limit_value, workspace_policies.member_monthly_credit_limit — these are display/soft-threshold values; authoritative amounts always use the *_micros fixed-point integers |
| AUTOINCREMENT → BIGSERIAL | Only usage_logs.id; usage_stats.source_log_id is an integer PK in both (SQLite INTEGER PRIMARY KEY, PG BIGINT) |
| Plaintext secrets | channels.api_key, oauth_providers.client_secret (Apple stores the .p8 key), payment_channels.config, mcp_servers.headers / user_mcp_servers.headers are stored in plaintext in the database and masked only by the API. Backups must be encrypted and access-restricted |
| Passwords | users.password_hash is bcrypt (store/ids.go), never plaintext |
Migration: there is no external migration tool
Aivory does not use goose / golang-migrate / Flyway and has no schema_migrations version table. The mechanism is a custom idempotent boot-time evolution (cmd/api/main.go → store.Open → store.Migrate → store.Seed, store.go:100-807):
- Pre-probes:
columnExists()(SELECT col FROM tbl WHERE 1=0) records whether a few columns pre-existed, as the criterion for "was it just added by this process". - Normalization: skill-name dedupe (
dedupeSkillNames), unique text field normalization (normalizeUniqueTextFields). - Apply embedded schema:
db.Exec(schemaSQL or schemaPGSQL)— allCREATE TABLE IF NOT EXISTS, side-effect-free on old databases. - Retirement cleanup: delete deprecated settings keys (
summary_target_percent,summary_merge_max_tokens). - Additive column evolution (~130
ALTER TABLE ADD COLUMNs): on SQLite a "duplicate column" error is expected and ignored; PG usesADD COLUMN IF NOT EXISTSfor a clean no-op. - One-time repair UPDATEs: role collapsing (
workspace_memberslegacyowner→admin, invalid →guest), workspace-conversation archive reset,session_idbackfill, payment snapshot backfill,documents.uploaded_by_user_idattribution, plus the backfill/cleanup idempotency markers listed in full on System & audit. - Legacy column removal:
chunks.embedding(vectors moved out) — PGDROP COLUMN IF EXISTS; SQLite first triesDROP COLUMNand otherwise rebuilds the table inside a transaction (RENAME → CREATE → copy rows via INSERT-SELECT → DROP → recreate vector_points,store.go:809-885). - Post-indexes: indexes that depend on additively-added columns run after the ALTERs; partial unique indexes here replace the schema-file definitions (project/KB names become the three-column
(user, workspace, name)form; skill/prompt/MCP name uniqueness splits into per-scope partial indexes,store.go:577-618). - Column-parity guard: every additively-migrated column must probe successfully per table — because step 5 swallows errors by design, a genuine failure aborts startup loudly here instead of surfacing later as "no such column" on a live server (
store.go:620-671). - Backfills and mirror: install the
usage_statstrigger,BackfillUsageStats, then a batch of one-time backfills gated by settings marker flags (or column existence) (msg_search_text_backfill_v1,user_sort_order_backfill_v1,user_onboarded_backfill_v1,oauth_pwset_backfill_v1,user_group_billing_prices_backfill_v2) — keyset-paged, best-effort, stamped once done. The markers are rows in thesettingstable itself — that is Aivory's "migration bookkeeping". Seed:INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO NOTHINGfor default settings + the always-presentug_freegroup. No admin is seeded — the first admin can only come from the first-run setup screen (POST /api/setup, see first run).
On manual schema changes
The only change path for DDL is editing Go source: schema*.sql + a new ALTER + the column-parity guard — all three together, shipped together. Hand-adding a column to a production database is invisible to the app (the column guard and queries do not know it) and can skew the next release's migration behavior.
Rules for manual DML (row-level) work:
- Back up first (next section) and work in a maintenance window.
- Personal edition: SQLite's WAL supports concurrent reads, but external writes contend with the app for the write lock (they error after
busy_timeout(5000)); stop the app or keep transactions very short. - Full edition: wrap a manual patch set in one transaction;
PRAGMA foreign_keysis per-connection in SQLite — the app's connections have it on, but external sqlite3 tooling does not by default — runPRAGMA foreign_keys = ON;first. - Keep boolean columns 0/1, JSON columns valid, time columns Unix seconds. For
settings.valueremember it is JSON-encoded — baretrueand"true"are different things. - Prefer the admin UI (including the Backup & Migration page's config export/import) over hand-edited SQL.
Backup and restore (data-layer view)
Upgrade, backup and restore owns the backup-scope matrix, principles, and acceptance; this section only adds the concrete commands that page lacks.
SQLite (personal): hot backup
Never cp a running WAL database (main file + -wal sidecars may tear). The app image has no sqlite3 CLI; run an online .backup from a throwaway container (it reads pages through a SQLite connection, inherently consistent):
cd /path/to/aivory/deploy
docker run --rm -v "$PWD/data-personal:/data" alpine:3 sh -c '
apk add --no-cache sqlite >/dev/null &&
sqlite3 /data/aivory.db ".backup /data/aivory-snapshot.db" &&
sqlite3 /data/aivory-snapshot.db "PRAGMA integrity_check;"'
For a full file-level restore, stop the app container and archive the whole DATA_DIR (database, vectors, uploads, artifacts, archives, backups — one unit).
PostgreSQL (full): pg_dump / pg_restore
cd /path/to/aivory/deploy
# custom compressed format, schema+data; user/db default to aivory (overridable in compose)
docker compose --env-file .env -f docker-compose.prod.yml \
exec -T postgres pg_dump -U aivory -d aivory -Fc \
> aivory-db-$(date +%F).dump
# restore (target db must exist and be truncatable; --clean --if-exists handles existing objects)
cat aivory-db-<date>.dump | docker compose --env-file .env -f docker-compose.prod.yml \
exec -T postgres pg_restore -U aivory -d aivory --clean --if-exists --no-owner
The usage_stats mirror trigger is not part of the logical dump's table data — after pg_restore the app reinstalls it at startup via Migrate; do not recreate it by hand.
Qdrant: snapshots
Qdrant publishes no host port in compose (internal network only), so snapshot commands run inside the container:
cd /path/to/aivory/deploy
# collections are named by dimension, typically aivory_c1536 (text-embedding-3-small) or aivory_c256 (bundled local embedder)
docker compose --env-file .env -f docker-compose.prod.yml exec qdrant \
curl -s -X POST -H "api-key: ${QDRANT_API_KEY:-aivory-internal-qdrant}" \
http://localhost:6333/collections/aivory_c1536/snapshots
# snapshot files land in /qdrant/snapshots on the qdrantdata volume; then copy them out:
docker compose --env-file .env -f docker-compose.prod.yml cp qdrant:/qdrant/snapshots ./qdrant-snapshots
Losing Qdrant is not losing your knowledge bases: the relational documents/chunks remain, but vector retrieval breaks and must be rebuilt (the admin "Storage & uploads" page offers vector maintenance; re-uploading documents also re-triggers ingest).
Redis: persistence meaning
The full stack runs AOF with --appendonly yes, volume redisdata. But Redis is never the authoritative store of anything; losing/purging it means:
- rate limits and login-failure counters reset (they re-accumulate);
- request-signature nonce dedupe sets empty (a short replay-protection window — restart the app after a purge);
- cross-process pub/sub (stop-generation, bans,
cfg:invalidate) only affects the moment in time; - SSE replay buffers are lost: an in-flight answer still finishes and persists server-side, just without event-by-event replay — a refresh reads the complete message from the DB;
- queued asynq RAG jobs are lost: documents not yet
readyare detected as stalled by the ingest heartbeat watchdog and can be re-triggered.
Operational advice: treat pgdata + Qdrant snapshots + DATA_DIR as a consistency triad and back them up together; the Redis volume is covered by the AOF default.
Common operational SQL
Replace time predicates per dialect: SQLite strftime('%s','now'), PostgreSQL extract(epoch from now())::bigint. The examples below run on both engines.
-- 1) users active in the last 24 hours
SELECT id, email, name, last_seen_at
FROM users
WHERE last_seen_at > strftime('%s','now') - 86400 AND status = 'active'
ORDER BY last_seen_at DESC;
-- on PG replace strftime('%s','now') with extract(epoch from now())::bigint (same below)
-- 2) per-model token spend and cost over 30 days (use the analytics source of truth;
-- unaffected by usage-log pruning)
SELECT s.model_id, m.label, m.currency,
SUM(s.input_tokens) AS input_tokens,
SUM(s.output_tokens) AS output_tokens,
SUM(s.cost) AS cost
FROM usage_stats s
LEFT JOIN models m ON m.id = s.model_id -- model_id survives deletion as a snapshot
WHERE s.created_at > strftime('%s','now') - 30 * 86400
GROUP BY s.model_id, m.label, m.currency
ORDER BY cost DESC;
-- 3) failed upstream requests (diagnostic detail)
SELECT created_at, user_id, model_id, purpose, channel_id, fallback, error
FROM usage_logs
WHERE status = 'error'
ORDER BY id DESC LIMIT 100;
-- 4) stuck RAG ingestion: mid-states with a stale heartbeat
SELECT id, kb_id, filename, status, ingest_updated_at
FROM documents
WHERE status IN ('pending','parsing','embedding')
AND ingest_updated_at < strftime('%s','now') - 90 * 60; -- 90 min without heartbeat = zombie candidate
-- 5) permanent-credit balance leaderboard (micros / 1e6)
SELECT u.email, u.credits_permanent_micros / 1000000.0 AS credits
FROM users u
WHERE u.credits_permanent_micros > 0
ORDER BY u.credits_permanent_micros DESC LIMIT 20;
-- 6) unclaimed admin credit-adjustment notices
SELECT user_id, direction, amount_micros / 1000000.0 AS amount, reason, created_at
FROM credit_adjustment_notifications
WHERE claimed_at = 0;
-- 7) redeem-code usage
SELECT r.code, r.kind, r.used_count, r.max_uses, COUNT(x.id) AS redemptions
FROM redeem_codes r
LEFT JOIN redeem_redemptions x ON x.code_id = r.id
GROUP BY r.id, r.code, r.kind, r.used_count, r.max_uses
ORDER BY redemptions DESC;
-- 8) async-delete residue: normally empty; non-empty means a purge was interrupted — an app restart sweeps it
SELECT path, user_id, created_at FROM pending_storage_cleanup ORDER BY created_at;
-- 9) prune diagnostic logs older than 180 days (stats unaffected — the mirror happens at INSERT time)
DELETE FROM usage_logs WHERE created_at < strftime('%s','now') - 180 * 86400;
-- 10) one knowledge base's size by document status
SELECT d.status, COUNT(DISTINCT d.id) AS docs, COALESCE(SUM(d.chunk_count),0) AS chunks
FROM documents d
WHERE d.kb_id = 'kb_1a2b3c4d5e6f'
GROUP BY d.status;
-- 11) workspace member permission inventory
SELECT w.name AS workspace, u.email, wm.role,
wm.can_create_skills, wm.can_delete_kb_content
FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
JOIN users u ON u.id = wm.user_id
ORDER BY w.name, u.email;
-- 12) timed-credit window reconciliation
SELECT user_id, group_id, cycle_anchor, SUM(amount_micros) / 1000000.0 AS spent
FROM credit_ledger
WHERE kind = 'timed_debit'
GROUP BY user_id, group_id, cycle_anchor
ORDER BY spent DESC LIMIT 20;
-- 13) SQLite-specific health checks (run via external sqlite3)
PRAGMA integrity_check; -- should print ok
PRAGMA foreign_key_check; -- should print nothing
SELECT name, SUM(pgsize)/1024/1024 AS mb FROM dbstat GROUP BY name ORDER BY mb DESC LIMIT 10;
SQLite's dbstat requires SQLITE_ENABLE_DBSTAT_VIRT at compile time (mattn/go-sqlite3 ships with it); if the table is missing, just du -h the DATA_DIR.
Related pages
- Upgrade, backup and restore — version pinning, backup scope matrix, acceptance and rollback
- Environment reference and advanced environment variables —
DATABASE_URL/REDIS_URL/QDRANT_URL/VECTOR_BACKENDrules - Cloudflare proxy notes — unrelated to the database but commonly debugged together
- First run and admin bootstrap — why the first
usersrow comes from the setup wizard, not a seed - Channels and models, Knowledge bases and RAG, Tools and sandbox, Billing and entitlements — admin UIs behind these tables
- Sandbox overview — why sandbox session state lives outside the database