# SQLite schema

This document describes the on-disk schema used by `contacts-server`. The schema is a **functional subset** of Android's `ContactsContract` — close enough that ATL can map URIs and column names directly, but not a literal clone. The SQLite database is the single source of truth; CardDAV vCards are generated on-the-fly and never stored.

## Database settings

The connection is opened with the following PRAGMAs:

| PRAGMA          | Value    | Reason                                                           |
| --------------- | -------- | ---------------------------------------------------------------- |
| `journal_mode`  | `WAL`    | Concurrent reads while a writer is active; needed for CardDAV-on-D-Bus parallelism. |
| `foreign_keys`  | `ON`     | Enforce referential integrity (e.g. `data.raw_contact_id`).      |
| `synchronous`   | `NORMAL` | Safe with WAL; faster than `FULL`. Power loss may lose the last commit, which is acceptable for contact data. |

Database file: `$XDG_DATA_HOME/liberux/contacts/contacts.db` (typically `~/.local/share/liberux/contacts/contacts.db`).

## Convention: timestamps

All timestamp columns store **milliseconds since the Unix epoch** as `INTEGER`. This matches Android's convention and avoids the date-string ambiguity of `TEXT` timestamps.

## Convention: no SQL triggers

We do **not** use SQL triggers to keep derived columns in sync (`raw_contacts.display_name`, `contacts._id` aggregation, `sync_state` bumps). All derivation lives in Rust, on top of the `db` module. Triggers were considered and rejected because:

- They make the persistence layer harder to reason about — invariants that hold "in code" are easier to test than invariants split between code and SQL.
- ApplyBatch semantics need atomic multi-row decisions that are awkward to express as triggers.
- Migrations are simpler when the schema has no procedural code.

## Tables

### `raw_contacts`

A `raw_contact` is a single source's view of a person — analogous to one row in Android's `RawContacts` table. Multiple raw_contacts that refer to the same person are aggregated into one `contacts` row (see "Aggregation" below).

```sql
CREATE TABLE raw_contacts (
    _id          INTEGER PRIMARY KEY AUTOINCREMENT,
    contact_id   INTEGER NOT NULL REFERENCES contacts( _id ) ON DELETE CASCADE,
    account_name TEXT,
    account_type TEXT,
    display_name TEXT,
    deleted      INTEGER NOT NULL DEFAULT 0,
    dirty        INTEGER NOT NULL DEFAULT 0,
    version      INTEGER NOT NULL DEFAULT 1,
    sourceid     TEXT,
    carddav_uid  TEXT,                 -- v2 migration; lazy UUIDv4
    created_at   INTEGER NOT NULL,
    updated_at   INTEGER NOT NULL
);

CREATE INDEX idx_raw_contacts_contact_id ON raw_contacts( contact_id );
CREATE INDEX idx_raw_contacts_account    ON raw_contacts( account_type, account_name );
CREATE INDEX idx_raw_contacts_dirty      ON raw_contacts( dirty ) WHERE dirty = 1;

CREATE UNIQUE INDEX idx_raw_contacts_carddav_uid
    ON raw_contacts( carddav_uid )
    WHERE carddav_uid IS NOT NULL;
```

| Column         | Notes                                                                              |
| -------------- | ---------------------------------------------------------------------------------- |
| `contact_id`   | FK to `contacts._id`. Many-to-one: many raw_contacts → one contact.               |
| `account_name` | E.g. `"liberux-local"` for entries created on-device. NULL allowed.               |
| `account_type` | E.g. `"org.liberux.local"`, `"com.google"`, etc.                                  |
| `display_name` | Denormalized from `data` rows of mimetype `name` for fast list rendering.          |
| `deleted`      | Soft-delete flag. Sync engines need to see deletions; row is hard-deleted only after the consumer has acknowledged. |
| `dirty`        | "Has local changes pending sync." Set by D-Bus mutations; cleared by the future sync engine. v1 has no remote sync, but the column is reserved. |
| `version`      | Bumped on every update. Used to short-circuit no-op syncs.                        |
| `sourceid`     | External id when the raw_contact mirrors a remote object on an upstream sync server. Distinct from `carddav_uid`. |
| `carddav_uid`  | UUIDv4 used as the file name in `/addressbook/main/<uid>.vcf`. Added in migration 0002, populated lazily on first CardDAV `GET`/`PROPFIND`. |
| `created_at` / `updated_at` | ms since epoch.                                                       |

### `contacts`

An aggregated person — one or more raw_contacts that we believe describe the same human.

```sql
CREATE TABLE contacts (
    _id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    display_name        TEXT,
    photo_id            INTEGER REFERENCES data( _id ) ON DELETE SET NULL,
    times_contacted     INTEGER NOT NULL DEFAULT 0,
    last_time_contacted INTEGER,
    starred             INTEGER NOT NULL DEFAULT 0,
    custom_ringtone     TEXT,
    send_to_voicemail   INTEGER NOT NULL DEFAULT 0,
    created_at          INTEGER NOT NULL,
    updated_at          INTEGER NOT NULL
);

CREATE INDEX idx_contacts_display_name ON contacts( display_name );
CREATE INDEX idx_contacts_starred      ON contacts( starred ) WHERE starred = 1;
```

| Column                | Notes                                                              |
| --------------------- | ------------------------------------------------------------------ |
| `display_name`        | Best-effort aggregate name. Picked from the "primary" raw_contact, falling back to whichever raw_contact has the most complete `name` data. |
| `photo_id`            | Points at a row in `data` with mimetype `…/photo`. `ON DELETE SET NULL` so deleting a photo doesn't cascade-delete the contact. |
| `times_contacted` / `last_time_contacted` | Updated by call/SMS apps via D-Bus (eydos-calls). |
| `starred`             | Boolean (0/1). Favorites.                                           |
| `custom_ringtone`     | URI or path; opaque to this server.                                 |
| `send_to_voicemail`   | Boolean.                                                            |

### `data`

The per-fact rows: a phone, an email, a postal address, a photo, etc. One raw_contact has many data rows.

```sql
CREATE TABLE data (
    _id               INTEGER PRIMARY KEY AUTOINCREMENT,
    raw_contact_id    INTEGER NOT NULL REFERENCES raw_contacts( _id ) ON DELETE CASCADE,
    mimetype          TEXT    NOT NULL,
    data1             TEXT,
    data2             TEXT,
    data3             TEXT,
    data4             TEXT,
    data5             TEXT,
    data6             TEXT,
    data7             TEXT,
    data8             TEXT,
    data9             TEXT,
    data10            TEXT,
    data11            TEXT,
    data12            TEXT,
    data13            TEXT,
    data14            TEXT,
    data15            BLOB,
    is_primary        INTEGER NOT NULL DEFAULT 0,
    is_super_primary  INTEGER NOT NULL DEFAULT 0,
    data_version      INTEGER NOT NULL DEFAULT 1,
    sync1             TEXT,
    sync2             TEXT,
    sync3             TEXT,
    sync4             TEXT,
    created_at        INTEGER NOT NULL,
    updated_at        INTEGER NOT NULL
);

CREATE INDEX idx_data_raw_contact   ON data( raw_contact_id );
CREATE INDEX idx_data_mimetype      ON data( mimetype );
CREATE INDEX idx_data_phone_search  ON data( data4 ) WHERE mimetype = 'vnd.android.cursor.item/phone_v2';
CREATE INDEX idx_data_email_search  ON data( data1 ) WHERE mimetype = 'vnd.android.cursor.item/email_v2';
```

Notes on column choices:

- `data1` through `data14` are `TEXT` to match Android's pragma. Numeric values (e.g. `TYPE` constants) are stored as decimal strings and parsed in Rust.
- `data15` is `BLOB` because it carries photo bytes for `…/photo`. For other mimetypes it is unused.
- `is_primary` marks the preferred entry within a raw_contact (e.g. main phone). `is_super_primary` is preferred across the whole aggregated contact.
- `sync1`–`sync4` are reserved for future sync engines (CardDAV ETags from upstream servers, Google sync metadata, etc.). Unused in v1.

### `groups`

```sql
CREATE TABLE groups (
    _id        INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    notes      TEXT,
    system_id  TEXT,
    deleted    INTEGER NOT NULL DEFAULT 0,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

CREATE TABLE group_membership (
    _id            INTEGER PRIMARY KEY AUTOINCREMENT,
    raw_contact_id INTEGER NOT NULL REFERENCES raw_contacts( _id ) ON DELETE CASCADE,
    group_id       INTEGER NOT NULL REFERENCES groups( _id )       ON DELETE CASCADE,
    UNIQUE( raw_contact_id, group_id )
);

CREATE INDEX idx_group_membership_raw   ON group_membership( raw_contact_id );
CREATE INDEX idx_group_membership_group ON group_membership( group_id );
```

Android also models group membership as a row in `data` with mimetype `…/group_membership`, but a dedicated junction table is clearer and serves the same queries. The D-Bus layer can synthesize `group_membership` data rows for ATL when a query demands the Android-shaped view.

### `sync_state`

```sql
CREATE TABLE sync_state (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL
);
```

Used as a small key/value store. Required keys:

| Key               | Value                                                            |
| ----------------- | ---------------------------------------------------------------- |
| `change_counter`  | Decimal monotonic counter, incremented on every write that affects exposed data. Drives the CardDAV `sync-token` (`data:,<value>`). |
| `schema_version`  | Decimal integer matching the migration index applied last.       |
| `instance_uuid`   | Stable UUID generated on first run; used in CardDAV `ctag`/resource ids. |

## Mimetypes (mandatory)

The following `data.mimetype` values must be supported in v1:

| Mimetype                                       | Purpose                       |
| ---------------------------------------------- | ----------------------------- |
| `vnd.android.cursor.item/name`                 | Structured name               |
| `vnd.android.cursor.item/phone_v2`             | Phone number                  |
| `vnd.android.cursor.item/email_v2`             | Email address                 |
| `vnd.android.cursor.item/postal-address_v2`    | Postal address                |
| `vnd.android.cursor.item/photo`                | Inline photo (BLOB)           |
| `vnd.android.cursor.item/organization`         | Organization + title          |
| `vnd.android.cursor.item/note`                 | Free-form note                |
| `vnd.android.cursor.item/contact_event`        | Birthday / anniversary / etc. |
| `vnd.android.cursor.item/im`                   | Instant messaging handle      |
| `vnd.android.cursor.item/website`              | URL                           |
| `vnd.android.cursor.item/nickname`             | Nickname / alias              |

Unknown mimetypes from clients are accepted and stored verbatim — `contacts-server` will round-trip them through D-Bus but will serialize them as `X-LIBERUX-<mimetype-derived>` in vCard output (see `vcard-mapping.md`).

## `data1..data15` mapping per mimetype

The mapping follows the AOSP convention (`android.provider.ContactsContract.CommonDataKinds`). Values not used by a mimetype are NULL.

### `…/name`

| Column  | Field                  |
| ------- | ---------------------- |
| `data1` | `display_name` (full)  |
| `data2` | `given_name`           |
| `data3` | `family_name`          |
| `data4` | `prefix`               |
| `data5` | `middle_name`          |
| `data6` | `suffix`               |
| `data7` | `phonetic_given`       |
| `data8` | `phonetic_middle`      |
| `data9` | `phonetic_family`      |

### `…/phone_v2`

| Column  | Field                                          |
| ------- | ---------------------------------------------- |
| `data1` | `number` (display form)                        |
| `data2` | `type` (int as string; see Android `Phone.TYPE_*`) |
| `data3` | `label` (custom label when `type = 0` / `CUSTOM`) |
| `data4` | `normalized_number` (E.164 if known)           |

### `…/email_v2`

| Column  | Field                                          |
| ------- | ---------------------------------------------- |
| `data1` | `address`                                      |
| `data2` | `type` (`Email.TYPE_*`: 1=HOME, 2=WORK, 3=OTHER, 4=MOBILE, 0=CUSTOM) |
| `data3` | `label`                                        |
| `data4` | `display_name` (cached "Name <addr>" form)     |

### `…/postal-address_v2`

| Column   | Field                |
| -------- | -------------------- |
| `data1`  | `formatted_address`  |
| `data2`  | `type`               |
| `data3`  | `label`              |
| `data4`  | `street`             |
| `data5`  | `pobox`              |
| `data6`  | `neighborhood`       |
| `data7`  | `city`               |
| `data8`  | `region`             |
| `data9`  | `postcode`           |
| `data10` | `country`            |

### `…/photo`

| Column   | Field                                                        |
| -------- | ------------------------------------------------------------ |
| `data14` | `photo_file_id` (reserved; v1 stores all photos inline)      |
| `data15` | `photo` BLOB (JPEG or PNG, raw bytes)                        |

### `…/organization`

| Column  | Field                |
| ------- | -------------------- |
| `data1` | `company`            |
| `data2` | `type`               |
| `data3` | `label`              |
| `data4` | `title`              |
| `data5` | `department`         |
| `data6` | `job_description`    |
| `data7` | `symbol`             |
| `data8` | `phonetic_name`      |
| `data9` | `office_location`    |

### `…/note`

| Column  | Field   |
| ------- | ------- |
| `data1` | `note`  |

### `…/contact_event`

| Column  | Field                                                                     |
| ------- | ------------------------------------------------------------------------- |
| `data1` | `start_date` (`YYYY-MM-DD` or `--MM-DD` per AOSP for unknown year)        |
| `data2` | `type` (1=ANNIVERSARY, 2=OTHER, 3=BIRTHDAY, 0=CUSTOM)                     |
| `data3` | `label`                                                                   |

### `…/im`

| Column  | Field                                                              |
| ------- | ------------------------------------------------------------------ |
| `data1` | `data` (handle/screen-name)                                        |
| `data2` | `type` (HOME/WORK/OTHER)                                           |
| `data3` | `label`                                                            |
| `data5` | `protocol` (int constant: 0=AIM, 1=MSN, 2=YAHOO, 3=SKYPE, 4=QQ, 5=GTALK, 6=ICQ, 7=JABBER, 8=NETMEETING, -1=CUSTOM) |
| `data6` | `custom_protocol`                                                  |

### `…/website`

| Column  | Field                                                                            |
| ------- | -------------------------------------------------------------------------------- |
| `data1` | `url`                                                                            |
| `data2` | `type` (1=HOMEPAGE, 2=BLOG, 3=PROFILE, 4=HOME, 5=WORK, 6=FTP, 7=OTHER, 0=CUSTOM) |
| `data3` | `label`                                                                          |

### `…/nickname`

| Column  | Field                                                                          |
| ------- | ------------------------------------------------------------------------------ |
| `data1` | `name`                                                                         |
| `data2` | `type` (1=DEFAULT, 2=OTHER_NAME, 3=MAIDEN_NAME, 4=SHORT_NAME, 5=INITIALS, 0=CUSTOM) |
| `data3` | `label`                                                                        |

## Aggregation (raw_contacts → contacts)

v1 aggregation rule: **one raw_contact per contact**, i.e. no merging. `raw_contacts.contact_id` always points at a contact row created together with the raw_contact.

This is intentional: cross-source aggregation (Google + local) is a known hard problem, and v1 has no second source. The schema is shaped so that v2 can introduce real aggregation without migration: the FK is already there, the aggregated `display_name` already lives in `contacts`, and downstream consumers (CardDAV, ATL) see one stable `contacts._id` per person.

The aggregation step is encapsulated in a single Rust function (`db::aggregator::recompute( raw_contact_id )`) called after every mutation. In v1 it is trivial (1:1 mapping); v2 swaps in a real implementation without touching callers.

## Derived columns and invariants

Maintained in Rust, not by triggers:

- `raw_contacts.display_name` — recomputed when any `…/name` data row attached to that raw_contact is inserted/updated/deleted. Falls back to phone/email/"Unknown" if no name is set.
- `contacts.display_name` — recomputed after aggregation. In v1, mirrors the single raw_contact's `display_name`.
- `contacts.photo_id` — set to the most recent `…/photo` data row attached to any of the contact's raw_contacts; cleared if no photo remains.
- `data_version`, `version` — bumped on every UPDATE of the row / its parent raw_contact.
- `updated_at` — set on every INSERT/UPDATE.
- `sync_state.change_counter` — incremented exactly once per externally observable mutation (a single `ApplyBatch` increments it once, not once per operation).

## Migrations

DDL lives in `src/db/schema.rs` as an ordered list of migration strings. `sync_state.schema_version` tracks the last applied index. On startup we apply any pending migrations in a single transaction.

Shipped migrations:

- **0001-initial** — every table and index above except `raw_contacts.carddav_uid`. Bootstraps `sync_state.change_counter = '0'`.
- **0002-carddav-uid** — adds the nullable `raw_contacts.carddav_uid TEXT` column and the partial UNIQUE index. Required by Phase 3 of the CardDAV implementation.

We never edit a migration once shipped; any further change becomes a new entry.

## What is NOT in v1

Documented here so the omissions are explicit:

- No `accounts` table (Android uses one to gate per-account sync). v1 has one implicit `liberux-local` account.
- No `presence` / `status_updates` (IM presence). Out of scope.
- No `directory` / per-app contact directories.
- No `phone_lookup` denormalization table — we rely on the `idx_data_phone_search` partial index over `data4`.
- No FTS / `name_lookup`. If query latency becomes a problem we add an FTS5 virtual table in v2.
