Database Schema
Complete documentation of the haex-vault database schema, including local SQLite tables and sync server schema.
Schema Overview
haex-vault uses a dual-database architecture: a local encrypted SQLite database on each device, and the haex-sync-server with its own database for cross-device replication.
Local Database
SQLite with SQLCipher encryption, storing all user data and extension tables with CRDT columns.
Sync Server
haex-sync-server: HTTP + WebSocket service that stores encrypted column-level changes. Authentication is DID-based; the server never sees plaintext data.
CRDT Columns
Every table that participates in sync has two special CRDT columns added automatically. Deletes are tracked separately via the haex_deleted_rows table - no tombstone column is added to your tables.
-- Added to all synced tables automatically
haex_hlc TEXT NOT NULL, -- Row-level Hybrid Logical Clock (max over all column HLCs)
haex_column_hlcs TEXT NOT NULL -- JSON object with HLC per column for column-level merging
DEFAULT '{}'
-- Deletes do NOT add a tombstone column. A BEFORE DELETE trigger logs deletions to haex_deleted_rows.Example Query
-- Query CRDT columns
SELECT
id,
title,
haex_hlc,
haex_column_hlcs
FROM haex_passwords
WHERE id = 'abc-123';
-- Result:
-- id: "abc-123"
-- title: "Gmail"
-- haex_hlc: "7B7E2C9E10000000/8a4e23f76b0a4d9faabbccddeeff0011"
-- haex_column_hlcs: '{"id":"7B7E2C800000000/8a4e...","title":"7B7E2C9E10000000/8a4e..."}'
Core Tables
These tables are part of haex-vault core and exist in every vault. They all participate in CRDT sync.
| Table Name | Description | CRDT |
|---|---|---|
| haex_extensions | Installed extensions metadata | Yes |
| haex_extension_permissions | Extension permissions granted by the user | Yes |
| haex_extension_migrations | Tracking of applied extension migrations | Yes |
| haex_sync_backends | Configured haex-sync-server backends | Yes |
| haex_identities | Local DID identities used to sign requests | Yes |
| haex_spaces | Shared spaces this vault participates in | Yes |
haex_sync_backends
CREATE TABLE haex_sync_backends (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
home_server_url TEXT NOT NULL, -- haex-sync-server base URL
space_id TEXT NOT NULL, -- Shared space identifier
identity_id TEXT NOT NULL, -- DID identity used for auth
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 0,
last_push_hlc TEXT, -- Last pushed HLC
last_pull_server_at TEXT, -- Last pull timestamp
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
-- CRDT columns added automatically:
haex_hlc TEXT NOT NULL,
haex_column_hlcs TEXT NOT NULL DEFAULT '{}'
);
CRDT Infrastructure
These tables support the sync engine but are NOT synced themselves (local-only).
| Table Name | Description | Synced |
|---|---|---|
| haex_crdt_configs | Per-device HLC node ID and persisted clock state | Local Only |
| haex_crdt_dirty_tables | Tables with pending local changes to sync | Local Only |
| haex_crdt_migrations | Applied schema migrations tracking | Local Only |
| haex_crdt_conflicts | Detected sync conflicts (debugging, local-only) | Local Only |
| haex_deleted_rows | Per-row delete log used to replicate deletions | Local Only |
haex_crdt_dirty_tables
-- Tracks tables with pending local changes.
-- Local-only, never synced.
CREATE TABLE haex_crdt_dirty_tables (
table_name TEXT PRIMARY KEY,
last_modified TEXT NOT NULL
);
-- Example entry after editing a password:
-- table_name: "haex_passwords"
-- last_modified: "2026-05-21 10:45:30"
-- Companion: haex_deleted_rows logs DELETE operations
-- per row so the sync server can replicate deletions.
CREATE TABLE haex_deleted_rows (
table_name TEXT NOT NULL,
row_pks TEXT NOT NULL, -- JSON: primary key columns
haex_hlc TEXT NOT NULL,
PRIMARY KEY (table_name, row_pks)
);
Extension Tables
Extensions can create their own tables. These are automatically namespaced to prevent conflicts.
-- Extension Table Naming Convention
{public_key}__{extension_name}__{table_name}
-- Examples:
b4401f13f65e576b__haex-pass__haex_passwords_items
b4401f13f65e576b__haex-pass__haex_passwords_groups
Automatic CRDT Setup:When an extension creates a table via migrations, CRDT columns (haex_hlc, haex_column_hlcs) and the dirty/delete triggers are added automatically. Extension tables sync just like core tables.
Sync Server Schema
The sync server (haex-sync-server) keeps an encrypted, append-only change log per space and authenticates clients via DID-signed requests.
vault_keys
-- Server-side: per-identity vault key metadata stored in the
-- haex-sync-server. Authentication is DID-based (the server never
-- sees the user's vault password).
CREATE TABLE vault_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL, -- Decentralized Identifier (DID)
vault_id TEXT NOT NULL,
encrypted_vault_key BYTEA NOT NULL, -- AES-256-GCM ciphertext
encrypted_vault_name BYTEA NOT NULL, -- AES-256-GCM ciphertext
vault_key_salt BYTEA NOT NULL,
vault_name_salt BYTEA NOT NULL,
vault_key_nonce BYTEA NOT NULL,
vault_name_nonce BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now(),
UNIQUE(did, vault_id)
);
sync_changes (Indexed per space)
-- Server-side change log. One row per (column, row, change).
-- The server only stores encrypted ciphertext; HLC ordering decides
-- replacement.
CREATE TABLE sync_changes (
space_id TEXT NOT NULL,
table_name TEXT NOT NULL,
row_pks JSONB NOT NULL, -- Primary key(s) as JSON
column_name TEXT NOT NULL,
encrypted_value BYTEA, -- AES-256-GCM ciphertext
nonce BYTEA NOT NULL,
haex_hlc TEXT NOT NULL, -- HLC of this column change
device_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT now(),
PRIMARY KEY (space_id, haex_hlc, table_name, row_pks, column_name)
);
-- Indexed for "give me everything since cursor X" pull queries.
CREATE INDEX ON sync_changes (space_id, haex_hlc);
Per-Space Authorization:Every read/write is authorized against the calling DID's membership in the space. The server cannot decrypt the contents - only authorized members can.
SQLite Triggers
CRDT tables get three triggers automatically: INSERT and UPDATE mark the table as dirty for the next push; BEFORE DELETE additionally logs the deleted row's PKs into haex_deleted_rows so the deletion can be replicated.
-- haex-vault installs three triggers per CRDT table.
-- Naming convention: z_dirty_{TABLE}_{insert|update|delete}
-- INSERT / UPDATE: mark the table as dirty so the sync
-- orchestrator picks it up on the next push.
CREATE TRIGGER IF NOT EXISTS "z_dirty_haex_passwords_insert"
AFTER INSERT ON "haex_passwords"
FOR EACH ROW BEGIN
INSERT OR REPLACE INTO haex_crdt_dirty_tables
(table_name, last_modified)
VALUES ('haex_passwords', datetime('now'));
END;
CREATE TRIGGER IF NOT EXISTS "z_dirty_haex_passwords_update"
AFTER UPDATE ON "haex_passwords"
FOR EACH ROW BEGIN
INSERT OR REPLACE INTO haex_crdt_dirty_tables
(table_name, last_modified)
VALUES ('haex_passwords', datetime('now'));
END;
-- DELETE: log the deletion in haex_deleted_rows BEFORE
-- the actual row is removed, then also mark dirty.
CREATE TRIGGER IF NOT EXISTS "z_dirty_haex_passwords_delete"
BEFORE DELETE ON "haex_passwords"
FOR EACH ROW BEGIN
INSERT INTO haex_deleted_rows
(table_name, row_pks, haex_hlc)
VALUES ('haex_passwords',
json_object('id', OLD.id),
/* current HLC */ ?);
INSERT OR REPLACE INTO haex_crdt_dirty_tables
(table_name, last_modified)
VALUES ('haex_passwords', datetime('now'));
END;
Auto-Generated:Triggers are created by the Rust backend when a table is set up for CRDT sync. You don't need to create them manually.