Skip to content

SQLite schema

The disk engine (SqliteCrdtTable) keeps one SQLite database per collection, opened with SQLCipher when an encryption key is supplied (the default through the builder). WAL journaling, synchronous = NORMAL.

Tables

CREATE TABLE crdt_records (
  collection TEXT NOT NULL,
  key        TEXT NOT NULL,           -- docId, or docId ␟ fieldPath
  value      TEXT NOT NULL,           -- JSON (a typed field stores its state)
  is_deleted INTEGER NOT NULL DEFAULT 0,
  hlc        TEXT NOT NULL,
  hlc_key    TEXT NOT NULL,           -- fixed-width sortable HLC encoding
  crdt_type  TEXT NOT NULL DEFAULT 'lww',
  doc_id     TEXT GENERATED ALWAYS AS (...) VIRTUAL,  -- key before ␟
  field_path TEXT GENERATED ALWAYS AS (...) VIRTUAL,  -- key after ␟, or NULL
  PRIMARY KEY (collection, key)
);

CREATE TABLE crdt_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);  -- node_id

Indexes: (collection, hlc_key) for watermark and range scans, (collection, doc_id) for single-document reads, plus one small partial expression index per path declared in indexedFields (json_extract(value, '$'), doc_id filtered to that collection and path), which serves query equality predicates.

Schema version

The schema version lives in PRAGMA user_version (currently 1). Schema creation, and any future migration, runs in one transaction with the version bump included, so an interrupted step reopens at the old version, never half-applied. The virtual doc_id / field_path columns are computed on read: they cost nothing on the write path.

Encryption is verified on open

When an encryption key is supplied, the engine issues PRAGMA key and then checks PRAGMA cipher_version. A linked SQLite that is not a SQLCipher build returns no row, and the open throws SqlCipherUnavailableException instead of silently writing plaintext. A key that does not decrypt an existing file throws WrongPassphraseException and leaves the file untouched, so a retry with the right passphrase succeeds.

Corruption recovery

Opening runs PRAGMA quick_check. A corrupt file is moved aside to <path>.corrupt-<timestamp> and the store is rebuilt: from <path>.snapshot when a readable one exists, otherwise empty, followed by an automatic re-sync from peers. snapshotInterval (default 30 minutes) controls how often the snapshot is refreshed with VACUUM INTO (the snapshot inherits the encryption key). Either recovery surfaces a StoreRecovered event on Passnote.events.

For an encrypted database the corrupt and mis-keyed cases can look identical: SQLCipher answers header damage and a wrong key with the same error. The snapshot arbitrates. When <path>.snapshot opens cleanly under the supplied key, the key is proven right, the file is treated as corrupt, and recovery proceeds. Without a snapshot the ambiguity resolves to WrongPassphraseException and the file is preserved: the engine never destroys data on a guess, at the price that recovering from header damage then takes the user confirming the passphrase and deleting the file. Damage past the header (a broken page under a valid key) fails quick_check directly and recovers without arbitration. Opening an encrypted file with no key also reads as corruption; the quarantine keeps the original file, so a lost key entry quarantines the working copy rather than deleting it.

Key rotation

rekey runs SQLCipher's PRAGMA rekey, re-encrypting the whole file in place. It is called automatically when the group key rotates after a device revocation.

What the file leaks

SQLCipher encrypts the whole file: schema, row counts, and content are all ciphertext to an attacker with only the file. Opening with a key on a non-SQLCipher build fails closed (see above), so a keyed database is never plaintext by accident. Only a database deliberately opened without an encryption key stores collection names, keys, HLCs, and values as plaintext SQLite.