Files
buzz/desktop/src-tauri/src/channel_head_cache.rs
Tyler 2d280376ad perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
## Summary

Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.

**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).

Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.

**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.

**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.

**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).

### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.

### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):

- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.

### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).

Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):

- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean

At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green

CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.

Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.

---------

Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
2026-08-24 10:11:07 -04:00

433 lines
14 KiB
Rust

//! Persistent native cache for recently visited channel head pages.
//!
//! The cache is a paint accelerator only: the renderer always replaces a
//! hydrated page with an authoritative relay response after subscribing.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{AppHandle, Manager, State};
const SCHEMA_VERSION: i64 = 1;
const CHANNELS_PER_SCOPE_CAP: i64 = 32;
const ROW_BYTES_CAP: usize = 1024 * 1024;
/// Serializes cache mutations on the blocking pool.
#[derive(Default)]
pub(crate) struct ChannelHeadCacheStore {
write_lock: Arc<Mutex<()>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelHeadScope {
pub(crate) pubkey: String,
pub(crate) relay_url: String,
}
impl ChannelHeadScope {
fn key(&self) -> String {
format!(
"{}:{}",
self.pubkey.trim().to_ascii_lowercase(),
self.relay_url.trim().trim_end_matches('/')
)
}
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelHeadEntry {
channel_id: String,
events: Vec<Value>,
saved_at: i64,
last_visited_at: i64,
}
fn db_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|error| format!("resolve channel-head cache data dir: {error}"))?;
std::fs::create_dir_all(&dir)
.map_err(|error| format!("create channel-head cache data dir: {error}"))?;
Ok(dir.join("channel-head-cache.db"))
}
fn create_schema(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE schema_meta(version INTEGER NOT NULL);
INSERT INTO schema_meta(version) VALUES(1);
CREATE TABLE channel_head(
scope TEXT NOT NULL,
channel_id TEXT NOT NULL,
events_json TEXT NOT NULL,
row_count INTEGER NOT NULL,
saved_at INTEGER NOT NULL,
last_visited_at INTEGER NOT NULL,
PRIMARY KEY(scope, channel_id)
);",
)
.map_err(|error| format!("initialize channel-head cache db: {error}"))
}
fn open_db(path: &Path) -> Result<Connection, String> {
let conn =
Connection::open(path).map_err(|error| format!("open channel-head cache db: {error}"))?;
conn.pragma_update(None, "busy_timeout", 5_000)
.map_err(|error| format!("configure channel-head cache db: {error}"))?;
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(|error| format!("configure channel-head cache WAL: {error}"))?;
let has_schema_meta: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta')",
[],
|row| row.get(0),
)
.map_err(|error| format!("inspect channel-head cache schema: {error}"))?;
if !has_schema_meta {
create_schema(&conn)?;
return Ok(conn);
}
let version = conn
.query_row("SELECT version FROM schema_meta LIMIT 1", [], |row| {
row.get::<_, i64>(0)
})
.optional()
.map_err(|error| format!("read channel-head cache schema: {error}"))?;
let has_channel_head: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='channel_head')",
[],
|row| row.get(0),
)
.map_err(|error| format!("inspect channel-head cache table: {error}"))?;
if version != Some(SCHEMA_VERSION) || !has_channel_head {
conn.execute_batch("DROP TABLE IF EXISTS channel_head; DROP TABLE IF EXISTS schema_meta;")
.map_err(|error| format!("reset channel-head cache schema: {error}"))?;
create_schema(&conn)?;
}
Ok(conn)
}
async fn run_blocking<T, F>(task: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> Result<T, String> + Send + 'static,
{
tauri::async_runtime::spawn_blocking(task)
.await
.map_err(|error| format!("channel-head cache db task failed: {error}"))?
}
fn load_from_path(
path: &Path,
scope: &ChannelHeadScope,
limit: u32,
) -> Result<Vec<ChannelHeadEntry>, String> {
let conn = open_db(path)?;
let mut statement = conn
.prepare(
"SELECT channel_id, events_json, saved_at, last_visited_at
FROM channel_head WHERE scope=?1
ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC LIMIT ?2",
)
.map_err(|error| format!("prepare channel-head cache load: {error}"))?;
let rows = statement
.query_map(params![scope.key(), i64::from(limit)], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(|error| format!("query channel-head cache: {error}"))?;
let mut entries = Vec::new();
for row in rows {
let (channel_id, events_json, saved_at, last_visited_at) =
row.map_err(|error| format!("read channel-head cache row: {error}"))?;
let events = match serde_json::from_str(&events_json) {
Ok(events) => events,
Err(error) => {
eprintln!("skipping corrupt channel-head cache row {channel_id}: {error}");
continue;
}
};
entries.push(ChannelHeadEntry {
channel_id,
events,
saved_at,
last_visited_at,
});
}
Ok(entries)
}
fn store_in_transaction(
transaction: &Transaction<'_>,
scope: &str,
channel_id: &str,
events_json: &str,
row_count: usize,
now: i64,
) -> Result<(), String> {
let last_visited_at: i64 = transaction
.query_row(
"SELECT COALESCE(MAX(last_visited_at), ?2 - 1) + 1 FROM channel_head WHERE scope=?1",
params![scope, now],
|row| row.get(0),
)
.map_err(|error| format!("advance channel-head cache visit clock: {error}"))?;
transaction
.execute(
"INSERT INTO channel_head(scope, channel_id, events_json, row_count, saved_at, last_visited_at)
VALUES(?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(scope, channel_id) DO UPDATE SET
events_json=excluded.events_json,
row_count=excluded.row_count,
saved_at=excluded.saved_at,
last_visited_at=excluded.last_visited_at",
params![scope, channel_id, events_json, row_count as i64, now, last_visited_at],
)
.map_err(|error| format!("store channel-head cache row: {error}"))?;
transaction
.execute(
"DELETE FROM channel_head WHERE rowid IN (
SELECT rowid FROM channel_head WHERE scope=?1
ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC
LIMIT -1 OFFSET ?2
)",
params![scope, CHANNELS_PER_SCOPE_CAP],
)
.map_err(|error| format!("prune channel-head cache: {error}"))?;
Ok(())
}
fn store_at(
path: &Path,
scope: &ChannelHeadScope,
channel_id: &str,
events: &[Value],
now: i64,
) -> Result<(), String> {
let events_json = serde_json::to_string(events)
.map_err(|error| format!("encode channel-head cache row: {error}"))?;
let mut conn = open_db(path)?;
let transaction = conn
.transaction()
.map_err(|error| format!("begin channel-head cache store: {error}"))?;
if events_json.len() > ROW_BYTES_CAP {
transaction
.execute(
"DELETE FROM channel_head WHERE scope=?1 AND channel_id=?2",
params![scope.key(), channel_id],
)
.map_err(|error| format!("drop oversized channel-head cache row: {error}"))?;
} else {
store_in_transaction(
&transaction,
&scope.key(),
channel_id,
&events_json,
events.len(),
now,
)?;
}
transaction
.commit()
.map_err(|error| format!("commit channel-head cache store: {error}"))
}
/// Loads the most recently visited channel heads for one identity and relay.
#[tauri::command]
pub(crate) async fn channel_head_cache_load(
scope: ChannelHeadScope,
limit: u32,
app: AppHandle,
) -> Result<Vec<ChannelHeadEntry>, String> {
let path = db_path(&app)?;
run_blocking(move || load_from_path(&path, &scope, limit)).await
}
/// Stores one raw channel-window response, dropping payloads above one MiB.
#[tauri::command]
pub(crate) async fn channel_head_cache_store(
scope: ChannelHeadScope,
channel_id: String,
events: Vec<Value>,
app: AppHandle,
store: State<'_, ChannelHeadCacheStore>,
) -> Result<(), String> {
let path = db_path(&app)?;
let write_lock = Arc::clone(&store.write_lock);
run_blocking(move || {
let _guard = write_lock.lock().map_err(|error| error.to_string())?;
store_at(
&path,
&scope,
&channel_id,
&events,
chrono::Utc::now().timestamp(),
)
})
.await
}
/// Clears all persisted channel heads for one identity and relay.
#[tauri::command]
pub(crate) async fn channel_head_cache_clear(
scope: ChannelHeadScope,
app: AppHandle,
store: State<'_, ChannelHeadCacheStore>,
) -> Result<(), String> {
let path = db_path(&app)?;
let write_lock = Arc::clone(&store.write_lock);
run_blocking(move || {
let _guard = write_lock.lock().map_err(|error| error.to_string())?;
let conn = open_db(&path)?;
conn.execute("DELETE FROM channel_head WHERE scope=?1", [scope.key()])
.map_err(|error| format!("clear channel-head cache scope: {error}"))?;
Ok(())
})
.await
}
pub(crate) fn flush(app: &AppHandle) {
if let Ok(path) = db_path(app) {
if let Ok(conn) = open_db(&path) {
let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scope() -> ChannelHeadScope {
ChannelHeadScope {
pubkey: "PK".into(),
relay_url: "wss://relay/".into(),
}
}
#[test]
fn serialized_entry_matches_typescript_contract() {
let actual = serde_json::to_value(ChannelHeadEntry {
channel_id: "general".into(),
events: vec![serde_json::json!({"id":"event"})],
saved_at: 42,
last_visited_at: 43,
})
.unwrap();
let expected = serde_json::json!({
"channelId":"general",
"events":[{"id":"event"}],
"savedAt":42,
"lastVisitedAt":43
});
assert_eq!(actual, expected);
let decoded: ChannelHeadScope = serde_json::from_value(serde_json::json!({
"pubkey":"PK", "relayUrl":"wss://relay/"
}))
.unwrap();
assert_eq!(decoded, scope());
assert_eq!(decoded.key(), "pk:wss://relay");
}
#[test]
fn enforces_lru_and_payload_caps() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("channel-head-cache.db");
for index in 0..=CHANNELS_PER_SCOPE_CAP {
store_at(
&path,
&scope(),
&format!("channel-{index:02}"),
&[serde_json::json!({"index":index})],
1_000 + index,
)
.unwrap();
}
let entries = load_from_path(&path, &scope(), 100).unwrap();
assert_eq!(entries.len(), CHANNELS_PER_SCOPE_CAP as usize);
assert_eq!(entries.first().unwrap().channel_id, "channel-32");
assert!(!entries.iter().any(|entry| entry.channel_id == "channel-00"));
let oversized = vec![Value::String("x".repeat(ROW_BYTES_CAP))];
store_at(&path, &scope(), "channel-32", &oversized, 2_000).unwrap();
let count: i64 = open_db(&path)
.unwrap()
.query_row(
"SELECT COUNT(*) FROM channel_head WHERE channel_id='channel-32'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn skips_corrupt_rows_without_blanketing_good_entries() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("channel-head-cache.db");
store_at(
&path,
&scope(),
"good-channel",
&[serde_json::json!({"id":"good-event"})],
1_000,
)
.unwrap();
let conn = open_db(&path).unwrap();
conn.execute(
"INSERT INTO channel_head VALUES(?1, 'bad-channel', 'not-json', 1, 1001, 1001)",
[scope().key()],
)
.unwrap();
drop(conn);
let entries = load_from_path(&path, &scope(), 12).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].channel_id, "good-channel");
assert_eq!(
entries[0].events,
vec![serde_json::json!({"id":"good-event"})]
);
}
#[test]
fn schema_mismatch_recreates_cache() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("channel-head-cache.db");
let conn = open_db(&path).unwrap();
conn.execute("UPDATE schema_meta SET version=99", [])
.unwrap();
conn.execute(
"INSERT INTO channel_head VALUES('scope','channel','[]',0,1,1)",
[],
)
.unwrap();
drop(conn);
let reset = open_db(&path).unwrap();
let version: i64 = reset
.query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
.unwrap();
let rows: i64 = reset
.query_row("SELECT COUNT(*) FROM channel_head", [], |row| row.get(0))
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
assert_eq!(rows, 0);
}
}