feat: schema v2 with the kept column and contact history, migrated at open

This commit is contained in:
randogoth 2026-09-29 00:16:54 +03:00
parent 21931c38d4
commit 96d2412370
3 changed files with 161 additions and 16 deletions

View file

@ -85,9 +85,9 @@ The intended call graph: `Store::open` once, `Account::new(master, rotations)` p
## The store schema
The schema is a contract, not an implementation detail: `SCHEMA_VERSION` is 1, written to SQLite's `PRAGMA user_version` at creation and checked at open, so a foreign importer — a migration from another client, a sync tool — can build rows directly instead of reading `store.rs`. `Store::open_in_memory()` gives an importer a scratch database with schema and version in place. Within a version the layout below is stable; a version bump comes with a documented migration.
The schema is a contract, not an implementation detail: `SCHEMA_VERSION` is 2, written to SQLite's `PRAGMA user_version` at creation and checked at open, so a foreign importer — a migration from another client, a sync tool — can build rows directly instead of reading `store.rs`. `Store::open_in_memory()` gives an importer a scratch database with schema and version in place. Within a version the layout below is stable; a version bump comes with a migration run automatically at open (version 1 stores gain the `kept` column and the `history` table in place), and only stores newer than the build are refused.
Every mail column holds a sealed envelope, never plaintext. Timestamps are Unix seconds; keys and ids are 32-byte blobs.
Every mail column holds a sealed envelope, never plaintext. Timestamps are Unix seconds, except `history.until`, which is milliseconds; keys and ids are 32-byte blobs.
| Table | Columns | Meaning and invariants |
|---|---|---|
@ -97,10 +97,11 @@ Every mail column holds a sealed envelope, never plaintext. Timestamps are Unix
| `accepted` | `address`, `identity`, `active`, `added_at` | Main-tier admission (sec 5.8). `identity` is frozen at acceptance because the token the correspondent holds is derived from it; `active = 0` is a block and keeps the row |
| `tokens` | `address`, `token`, `seen_at` | Accept tokens correspondents issued us, filed under their address, which outlives the keys behind it (sec 5.8) |
| `seen` | `id`, `at` | Every message id ever fetched; a replay is not re-stored even after a local delete (sec 10) |
| `inbox` | `id`, `envelope`, `received_at`, `tier` | Sealed mail; `tier` is 0 main, 1 requests (arrived without a matching accept token) |
| `inbox` | `id`, `envelope`, `received_at`, `tier`, `kept` | Sealed mail; `tier` is 0 main, 1 requests (arrived without a matching accept token); `kept` records whether a server copy existed when the message was stored, so a local delete can offer to remove it there too — it is written once at first store and does not track later server-side deletes |
| `history` | `address`, `identity`, `until` | Keys a contact used before its current one, with when each stopped being current (epoch ms): the local record that a rotation happened, written when a signed chain displaces the key held (sec 7, sec 8) |
| `sent` | `id`, `recipient`, `envelope`, `sent_at` | Sealed self-copies (sec 5.6), with the recipient for the listing |
An importer should set `user_version` to 1, keep the single `state` row, and respect the frozen `accepted.identity` and `sync_ok` semantics above — those are the two rows where a wrong guess loses data (an erased server-side token set, or a contact's token silently changing).
An importer should set `user_version` to 2 (a v1 store is migrated on open instead), keep the single `state` row, and respect the frozen `accepted.identity` and `sync_ok` semantics above — those are the two rows where a wrong guess loses data (an erased server-side token set, or a contact's token silently changing).
## Modules