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 NameDescriptionCRDT
haex_extensionsInstalled extensions metadata
Yes
haex_extension_permissionsExtension permissions granted by the user
Yes
haex_extension_migrationsTracking of applied extension migrations
Yes
haex_sync_backendsConfigured haex-sync-server backends
Yes
haex_identitiesLocal DID identities used to sign requests
Yes
haex_spacesShared 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 NameDescriptionSynced
haex_crdt_configsPer-device HLC node ID and persisted clock state
Local Only
haex_crdt_dirty_tablesTables with pending local changes to sync
Local Only
haex_crdt_migrationsApplied schema migrations tracking
Local Only
haex_crdt_conflictsDetected sync conflicts (debugging, local-only)
Local Only
haex_deleted_rowsPer-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.