perf(chat): ingest a message batch in one transaction

Ingesting a scan's worth of chat messages persisted each new message
with its own append() call, so a batch of fresh messages opened and
committed one SQLite transaction per row.

- A new appendBatch persists many messages in a single transaction,
  using the same INSERT OR IGNORE dedup key as append() and preserving
  input order so load()'s rowid ordering is unchanged.
- Every message is serialized and encrypted before the transaction
  opens, so a message with a non-UTF-8 body is skipped on its own
  without leaving a transaction open; the valid siblings still persist.
- Ingest collects the newly stored messages and writes them once after
  the loop instead of per message. It is inert (returns 0) while locked
  or on empty input.

Tests cover a batch insert, dedup on re-insert, empty input, bad-UTF-8
isolation with a valid sibling and a following append(), insert order,
and the locked no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 22:03:00 -05:00
co-authored by Claude Opus 4.8
parent 1416654385
commit 3fd52fbdaf
4 changed files with 86 additions and 1 deletions
+28
View File
@@ -14274,6 +14274,34 @@ void testHushChatDatabase()
EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed);
}
// appendBatch: one transaction, dedup, order, tombstone-skip, bad-UTF-8 isolation.
scrub(dbPath);
{
ChatDatabase db(dbPath);
EXPECT_TRUE(db.unlockWithSecret(seedA));
std::vector<ChatMessage> batch = {
makeMsg("b1", 0, "c", "one", ChatMessageKind::Message, 1),
makeMsg("b2", 0, "c", "two", ChatMessageKind::Message, 2),
makeMsg("b3", 0, "c", "three", ChatMessageKind::Message, 3),
};
EXPECT_EQ(db.appendBatch(batch), 3);
EXPECT_EQ(db.appendBatch(batch), 0); // dedup: all ignored
EXPECT_EQ((int)db.appendBatch({}), 0); // empty → no-op
// bad UTF-8 body is skipped, the valid sibling still persists, and append() still works after.
ChatMessage bad = makeMsg("b4", 0, "c", std::string("\xff\xfe", 2), ChatMessageKind::Message, 4);
ChatMessage good = makeMsg("b5", 0, "c", "five", ChatMessageKind::Message, 5);
EXPECT_EQ(db.appendBatch({bad, good}), 1); // only good persisted, no open txn left
EXPECT_TRUE(db.append(makeMsg("b6", 0, "c", "six", ChatMessageKind::Message, 6)));
std::vector<ChatMessage> all = db.load();
EXPECT_EQ((int)all.size(), 5); // b1,b2,b3,b5,b6
EXPECT_EQ(all[0].body, std::string("one")); // rowid/insert order
EXPECT_EQ(all[4].body, std::string("six"));
}
{
ChatDatabase locked(dbPath); // no unlock → inert
EXPECT_EQ(locked.appendBatch({makeMsg("x", 0, "c", "x", ChatMessageKind::Message, 0)}), 0);
}
scrub(dbPath); scrub(dbPath2);
}