Examples on this page target source snapshot
35a69a14, not a crates.io release. Use the current API guide to select the matching dependency.Custom backends must explicitly implement atomic patch commits and token merges, plus the bounded pending-message operations below. A merge into main does not identify a published release.Backend
ImplementSignalStore, AppSyncStore, ProtocolStore, MsgSecretStore and DeviceStore; the blanket implementation supplies Backend. Use the source trait reference for exact signatures and required/default method distinctions.
Use storage concepts for the ownership model and custom backends before implementing an adapter. A collection of copied SQL snippets is not a complete backend contract.
Atomic app-state and token operations
In the merged #1681 contract,AppSyncStore::commit_patch and ProtocolStore::{touch_tc_token_sender_timestamp, store_received_tc_token} are required methods. Custom backends that inherited their former defaults must implement them before compiling against this contract. Their argument and result types remain unchanged.
commit_patch stores a collection’s version/hash and removed/added MACs as one atomic operation. Apply removals before additions. On failure or cancellation, the visible state must be the complete old or complete new patch, never a partial combination. An error may follow a complete commit if a durability barrier fails; it does not establish rollback.
The token methods atomically merge their respective fields in the device scope. Sender timestamps advance by maximum. Received bytes and their timestamp use newer-wins, accepting equal timestamps and replacing empty placeholders regardless of the placeholder timestamp. Preserve the other writer’s fields.
Follow the custom-backend implementation guidance for transactions, shared locks, callback adapters, and post-commit durability errors.
Pending-inbound durability operations
At main35a69a14, message durability requires ProtocolStore::read_pending_inbound_bounded, read_pending_inbound_group_bounded, replace_pending_inbound_if and delete_pending_inbound_if. Their unsupported defaults do not make an adapter durability-capable. The builder probes these operations and rejects unsupported adapters with UnsupportedDurabilityBackend.
Exact and unique group-alias reads must enforce the supplied bound before allocating payloads and share a row snapshot. Conditional replacement/deletion must atomically compare the observed revision and preserve conflicting successors. Keep leases and exclusive ownership alive through detached workers and cancellation. Implement the complete bounded storage semantics, not an unbounded-read fallback or an unconditional delete. Passing the probe does not certify crash durability or batch atomicity.
MsgSecretStore
The named read validates key material and preserves the parent’s timestamp. The required methods areput_msg_secrets, get_stored_msg_secret and delete_expired_msg_secrets. put_msg_secret is a single-entry default write; get_msg_secret is a byte-only default projection of the named read. There is no get_msg_secret_with_ts fallback.
MessageSecret and message_ts: Option<i64>. A persisted zero maps to None; retain a known parent time. For a backend that never stored timestamps, explicitly return StoredMessageSecret::new(secret, None). Do not substitute a row creation time or expiry deadline for the parent’s event time.
Malformed persisted bytes return StoreError::InvalidMessageSecret. Reads must not pad, truncate or log the bytes. The MessageSecret wrapper redacts its debug output; explicitly exporting its bytes still exposes the secret.
MsgSecretEntry stores JID/ID keys as Arc<str>, a protocol-sized [u8; 32], expires_at and message_ts. Upserts must merge expiry with merge_msg_secret_expiry (zero means never, otherwise later wins) and time with merge_msg_secret_message_ts (later non-zero wins). Retention deadlines and parent timestamps remain separate properties. Delete only non-zero expiry values at or before the cutoff.
A custom backend must implement the named read explicitly; it does not inherit a default that discards timestamp metadata. See current contracts and the source trait for exact method signatures and backend obligations.
MsgSecretStore, StoredMessageSecret and merge rules
SqliteStore implementation
The native crate uses Diesel and SQLite. Default SDK features bundle SQLite; opt intosqlite-storage without the bundled feature to use your compatible system library. WASM uses the supported browser storage path and local futures rather than native blocking/thread-pool adapters.
SqliteStore::open(url) selects device 1. For multi-account or database tuning, open SqliteDatabase with SqliteDatabaseConfig, then select or provision an account.
Account lifecycle
Selection is not provisioning.
provision_device is not an idempotent ensure and fails when the row already exists. SQLite allocates new IDs atomically and does not reissue removed IDs.
Stop the account’s background work before reset or removal. Existing handles are not revoked; later writes through them can affect recreated state. Removing one account does not stop another account’s client.
Old SqliteStoreConfig, per-device opening, sibling creation and database-management methods moved to SqliteDatabaseConfig and SqliteDatabase. See current contracts.
Database configuration
Configure database resources once. Defaults include one writer, one eligible reader, a 512 KiB page cache per connection andNormal synchronization. Reader concurrency applies only when the connection’s WAL/cache/native conditions support it; it does not create an additional SQLite writer.
Keep writer pool_size at 1: concurrent deferred read-to-write transaction upgrades can deadlock, and busy_timeout cannot break that deadlock. Use read_pool_size for read concurrency.
Use supported builders such as with_read_pool_size, with_mmap_size, with_connection_init, with_commit_barrier and with_incremental_vacuum. The source documents each hook’s order, target support and failure behavior. A commit barrier is a durability decision, not just a throughput knob.
SharedSqlite
database.shared() provides database-wide read/run access using the same pools, permits and barrier. It is not device-scoped; custom tables must enforce their own account predicates. A shared adapter can outlive an individual database facade or selected account handle.
Use read access for read-only operations. A read permit is not permission to mutate data or to bypass the writer’s transaction and durability policy.
StoredDeviceSummary
list_devices() returns summaries with these fields:
A provisioned row can exist before pairing, so existence alone does not imply
linked. These summaries contain account identities; redact them in diagnostic logs.
Resource reports
SqliteDatabase::resource_report() measures the database/pool scope; count it once per opened database, not once per account clone. SqliteStore::scoped_resource_report() describes the account scope. The legacy DeviceStore::resource_report reports the shared pool for compatibility, so adding it once per account double-counts that resource.
Reports are best-effort. Missing measurements remain absent rather than being invented as zero.
Migrations and errors
The adapter applies its checked-in schema migrations at opening. Back up persistent accounts before changing storage formats. Follow the supported legacy session interoperability path when importing older records; do not hand-modify Signal state to bypass errors. Inspect typedStoreError causes. Malformed message secrets use InvalidMessageSecret, and missing account operations can return DeviceNotFound. Preserve errors and timestamps instead of substituting empty values. See error handling.
Source reference
Storage traits · Database facade · Account store and configuration · Shared adapterTCToken storage
ProtocolStore::store_received_tc_token and get_tc_tokens support privacy-token persistence and batched reads. Received-token writes must preserve newer-wins semantics atomically; a byte-less placeholder can accept the first real token. The built-in SQLite adapter performs its check and write in an immediate transaction, while the in-memory backend holds its state lock. In the merged #1681 contract, custom adapters must explicitly implement the atomic merge so concurrent notification and identity-migration writes preserve the other fields. See the privacy token reference. Use the trait contract.