fix history

ober

0aefeed8985ad16c08bae703e3f7ba67effb13af

diff --git a/Makefile b/Makefile
index 6c60559..85637b6 100644
--- a/Makefile
+++ b/Makefile
@@ -35,8 +35,10 @@ else
 TUI_SHIM_EXT := so
 endif
 TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
+LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT)
+SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null)
 
-.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite
+.PHONY: all build binary run run-tui test install install-log-shim clean help vendor-deps tui-shim log-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite
 .DEFAULT_GOAL := help
 
 all: binary
@@ -72,11 +74,18 @@ install: binary
 	  fi; \
 	fi
 	test ! -f signal_tui_shim.$(TUI_SHIM_EXT) || install -m 0755 signal_tui_shim.$(TUI_SHIM_EXT) $(BIN_DIR)/signal_tui_shim.$(TUI_SHIM_EXT)
+	test ! -f $(LOG_SHIM) || install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM)
 	@echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)"
 
+install-log-shim: log-shim
+	mkdir -p $(BIN_DIR)
+	install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM)
+	@echo "Installed legacy $(LOG_SHIM) to $(BIN_DIR)/$(LOG_SHIM)"
+
 clean:
 	rm -f $(BIN)
 	rm -f signal_tui_shim.dylib signal_tui_shim.so
+	rm -f signal_log_shim.dylib signal_log_shim.so
 	find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true
 
 vendor-deps: ensure-jsqlite vendor/termbox2
@@ -138,6 +147,17 @@ tui-shim: vendor/termbox2
 	    signal/tui/signal_tui_shim.c
 	cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT)
 
+log-shim:
+	@if [ -z "$(SQLCIPHER_PREFIX)" ]; then \
+	  echo "ERROR: SQLCipher is required for legacy message logs; install it with Homebrew or set SQLCIPHER_PREFIX."; \
+	  exit 1; \
+	fi
+	cc -shared -fPIC \
+	  -I$(SQLCIPHER_PREFIX)/include/sqlcipher \
+	  -L$(SQLCIPHER_PREFIX)/lib -lsqlcipher \
+	  -o $(LOG_SHIM) \
+	  signal/log_shim.c
+
 help:
 	@echo "jerboa-signal -- Signal client over signal-cli"
 	@echo ""
@@ -147,8 +167,10 @@ help:
 	@echo "  run-tui             Build shim and start the TUI"
 	@echo "  test                Run tests"
 	@echo "  install             Install ./jerboa-signal to ~/.local/bin"
+	@echo "  install-log-shim    Install SQLCipher shim for legacy logs"
 	@echo "  ensure-jerboa-tools Ensure jerboa/jerbuild are available"
 	@echo "  tui-shim            Build the termbox2 TUI shim"
+	@echo "  log-shim            Build the SQLCipher legacy log shim"
 	@echo "  clean               Remove build artifacts"
 	@echo ""
 	@echo "Prerequisite: signal-cli must be linked to your Signal account."
diff --git a/README.md b/README.md
index aa7fbab..a8870d9 100644
--- a/README.md
+++ b/README.md
@@ -97,9 +97,10 @@ too. Use Ctrl-Q or Ctrl-C to quit.
 
 The TUI can log every message to an encrypted SQLite database so you keep a copy
 even when the sender later deletes it ("delete for everyone"). Logs use
-`jsqlite` for the SQLite engine and a Jerboa-native encrypted container around
-the database image; contents and metadata are unreadable without your
-passphrase. No native SQLite or SQLCipher library is loaded.
+`jsqlite` for new logs and a Jerboa-native encrypted container around the
+database image; contents and metadata are unreadable without your passphrase.
+Existing SQLCipher logs are still opened through the legacy shim so old history
+continues to replay.
 
 Enable it:
 
@@ -117,6 +118,15 @@ JSON); remote-deletes, edits, reactions, calls, attachments, and other
 displayable events are logged as new rows and never overwrite the
 original.
 
+To convert an older SQLCipher log to the current jsqlite container format:
+
+```sh
+jerboa-signal migrate-log     # add -a +PHONE if you use a named account
+```
+
+The converter verifies the new row count before swapping files and keeps the
+old SQLCipher database beside it as `messages-<account>.db.sqlcipher.bak`.
+
 Logging happens only while the TUI is open — messages that arrive while it is
 closed are not captured.
 
@@ -141,7 +151,7 @@ Modules:
 - `signal/cmd-tui.ss` — TUI command entry point; see `docs/TUI_PLAN.md`
 - `signal/store.ss` — persistent local state (deleted-conversation list)
 - `signal/capture.ss` — normalizes a notification into an encrypted log row
-- `signal/logdb.ss` + `signal/log_crypto.ss` — encrypted jsqlite log backend
+- `signal/logdb.ss` + `signal/log_crypto.ss` — encrypted log backend
 - `signal/tui/` — termbox2 FFI and the first terminal shell
 
 ## License
diff --git a/signal/cmd-migrate-log.ss b/signal/cmd-migrate-log.ss
new file mode 100644
index 0000000..39e4b36
--- /dev/null
+++ b/signal/cmd-migrate-log.ss
@@ -0,0 +1,79 @@
+#!chezscheme
+;;; signal/cmd-migrate-log -- convert legacy SQLCipher logs to jsqlite.
+
+(library (signal cmd-migrate-log)
+  (export cmd-migrate-log)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (signal logdb)
+          (signal store))
+
+  (def (cmd-migrate-log account)
+    (let* ([acct (or account "default")]
+           [path (messages-store-path acct)]
+           [env (getenv "JERBOA_SIGNAL_DB_KEY")]
+           [key (if (and env (not (string=? env "")))
+                  env
+                  (logdb-prompt-passphrase
+                    "jerboa-signal: passphrase for encrypted message log: "))])
+      (cond
+        [(or (not key) (string=? key ""))
+         (display "jerboa-signal: migration cancelled; no passphrase entered\n"
+                  (current-error-port))
+         (exit 2)]
+        [else
+         (ensure-store-dir!)
+         (report-migration-result
+           (logdb-migrate-legacy-to-jsqlite path key))])))
+
+  (def (report-migration-result result)
+    (case (car result)
+      [(migrated)
+       (display "Migrated encrypted log to jsqlite.\n")
+       (display "Rows copied: ")
+       (display (list-ref result 3))
+       (newline)
+       (display "New log: ")
+       (display (list-ref result 1))
+       (newline)
+       (display "Legacy backup: ")
+       (display (list-ref result 2))
+       (newline)]
+      [(already-jsqlite)
+       (display "Encrypted log is already in jsqlite format.\n")
+       (display "Rows: ")
+       (display (list-ref result 2))
+       (newline)]
+      [(missing)
+       (display "jerboa-signal: no encrypted log found at "
+                (current-error-port))
+       (display (list-ref result 1) (current-error-port))
+       (newline (current-error-port))
+       (exit 1)]
+      [(open-failed)
+       (display "jerboa-signal: could not open legacy log; wrong passphrase or missing SQLCipher shim\n"
+                (current-error-port))
+       (exit 1)]
+      [(read-failed)
+       (display "jerboa-signal: could not read all legacy rows; migration aborted\n"
+                (current-error-port))
+       (exit 1)]
+      [(verify-failed)
+       (display "jerboa-signal: converted log failed verification; migration aborted\n"
+                (current-error-port))
+       (exit 1)]
+      [else
+       (display "jerboa-signal: unexpected migration result\n"
+                (current-error-port))
+       (exit 1)]))
+
+  ) ;; end library
diff --git a/signal/log_shim.c b/signal/log_shim.c
new file mode 100644
index 0000000..7230b85
--- /dev/null
+++ b/signal/log_shim.c
@@ -0,0 +1,173 @@
+/* signal_log_shim.c -- SQLCipher-backed encrypted message log for jerboa-signal.
+ *
+ * Captures every Signal event as an append-only row so messages survive even
+ * when the sender later deletes them ("delete for everyone" / remote delete):
+ * a remote-delete is just another row and never touches the original.
+ *
+ * The database is a real encrypted SQLite file (SQLCipher, AES-256) -- contents
+ * AND metadata are unreadable without the key.
+ *
+ * Build:
+ *   cc -shared -fPIC \
+ *      -I$(brew --prefix sqlcipher)/include/sqlcipher \
+ *      -L$(brew --prefix sqlcipher)/lib -lsqlcipher \
+ *      -o signal_log_shim.dylib signal/log_shim.c
+ */
+
+#include <sqlite3.h>
+#include <string.h>
+
+/* SQLCipher exports this, but sqlite3.h only declares it under
+ * SQLITE_HAS_CODEC. Declare directly so we link against libsqlcipher's symbol
+ * without depending on the header guard. */
+extern int sqlite3_key(sqlite3 *db, const void *pKey, int nKey);
+
+static const char *SCHEMA =
+    "CREATE TABLE IF NOT EXISTS messages ("
+    "  id           INTEGER PRIMARY KEY AUTOINCREMENT,"
+    "  logged_at    INTEGER DEFAULT (strftime('%s','now')),"
+    "  account      TEXT,"
+    "  direction    TEXT,"
+    "  conversation TEXT,"
+    "  sender       TEXT,"
+    "  timestamp    INTEGER,"
+    "  kind         TEXT,"
+    "  body         TEXT,"
+    "  raw          TEXT NOT NULL"
+    ");"
+    "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation);"
+    "CREATE INDEX IF NOT EXISTS idx_messages_ts   ON messages(timestamp);";
+
+void *signal_log_open(const char *path, const char *key) {
+    sqlite3 *db = NULL;
+    char *err = NULL;
+
+    if (sqlite3_open(path, &db) != SQLITE_OK) {
+        if (db) sqlite3_close(db);
+        return NULL;
+    }
+    if (key && key[0] != '\0') {
+        if (sqlite3_key(db, key, (int)strlen(key)) != SQLITE_OK) {
+            sqlite3_close(db);
+            return NULL;
+        }
+    }
+    if (sqlite3_exec(db, "SELECT count(*) FROM sqlite_master;",
+                     NULL, NULL, &err) != SQLITE_OK) {
+        if (err) sqlite3_free(err);
+        sqlite3_close(db);
+        return NULL;
+    }
+    if (sqlite3_exec(db, SCHEMA, NULL, NULL, &err) != SQLITE_OK) {
+        if (err) sqlite3_free(err);
+        sqlite3_close(db);
+        return NULL;
+    }
+    (void)sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
+    return (void *)db;
+}
+
+int signal_log_close(void *handle) {
+    if (!handle) return 0;
+    return sqlite3_close((sqlite3 *)handle);
+}
+
+static void bind_text_or_null(sqlite3_stmt *st, int idx, const char *s) {
+    if (s && s[0] != '\0')
+        sqlite3_bind_text(st, idx, s, -1, SQLITE_TRANSIENT);
+    else
+        sqlite3_bind_null(st, idx);
+}
+
+int signal_log_put(void *handle,
+                   const char *account, const char *direction,
+                   const char *conversation, const char *sender,
+                   long long timestamp, const char *kind,
+                   const char *body, const char *raw) {
+    if (!handle) return -1;
+    sqlite3 *db = (sqlite3 *)handle;
+    static const char *SQL =
+        "INSERT INTO messages"
+        " (account,direction,conversation,sender,timestamp,kind,body,raw)"
+        " VALUES (?,?,?,?,?,?,?,?);";
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK)
+        return sqlite3_errcode(db);
+    bind_text_or_null(st, 1, account);
+    bind_text_or_null(st, 2, direction);
+    bind_text_or_null(st, 3, conversation);
+    bind_text_or_null(st, 4, sender);
+    sqlite3_bind_int64(st, 5, (sqlite3_int64)timestamp);
+    bind_text_or_null(st, 6, kind);
+    bind_text_or_null(st, 7, body);
+    sqlite3_bind_text(st, 8, raw ? raw : "", -1, SQLITE_TRANSIENT);
+    int rc = sqlite3_step(st);
+    sqlite3_finalize(st);
+    return (rc == SQLITE_DONE) ? 0 : rc;
+}
+
+long long signal_log_count(void *handle) {
+    if (!handle) return -1;
+    sqlite3 *db = (sqlite3 *)handle;
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, "SELECT count(*) FROM messages;", -1, &st, NULL)
+            != SQLITE_OK)
+        return -1;
+    long long n = -1;
+    if (sqlite3_step(st) == SQLITE_ROW)
+        n = (long long)sqlite3_column_int64(st, 0);
+    sqlite3_finalize(st);
+    return n;
+}
+
+void *signal_log_recent(void *handle, long long limit) {
+    if (!handle) return NULL;
+    sqlite3 *db = (sqlite3 *)handle;
+    static const char *SQL =
+        "SELECT direction, conversation, sender, timestamp, kind, body"
+        " FROM messages"
+        " WHERE direction IN ('in','out')"
+        "   AND conversation IS NOT NULL AND conversation <> ''"
+        "   AND body IS NOT NULL AND body <> ''"
+        " ORDER BY id DESC LIMIT ?;";
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK)
+        return NULL;
+    sqlite3_bind_int64(st, 1, (sqlite3_int64)(limit > 0 ? limit : 0));
+    return (void *)st;
+}
+
+void *signal_log_all_rows(void *handle) {
+    if (!handle) return NULL;
+    sqlite3 *db = (sqlite3 *)handle;
+    static const char *SQL =
+        "SELECT logged_at, account, direction, conversation, sender,"
+        "       timestamp, kind, body, raw"
+        " FROM messages"
+        " ORDER BY id ASC;";
+    sqlite3_stmt *st = NULL;
+    if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK)
+        return NULL;
+    return (void *)st;
+}
+
+int signal_log_row_step(void *stmt) {
+    if (!stmt) return 0;
+    return sqlite3_step((sqlite3_stmt *)stmt) == SQLITE_ROW ? 1 : 0;
+}
+
+const char *signal_log_row_text(void *stmt, int col) {
+    if (!stmt) return "";
+    const unsigned char *s = sqlite3_column_text((sqlite3_stmt *)stmt, col);
+    return s ? (const char *)s : "";
+}
+
+long long signal_log_row_int(void *stmt, int col) {
+    if (!stmt) return 0;
+    return (long long)sqlite3_column_int64((sqlite3_stmt *)stmt, col);
+}
+
+int signal_log_row_close(void *stmt) {
+    if (!stmt) return 0;
+    return sqlite3_finalize((sqlite3_stmt *)stmt);
+}
diff --git a/signal/logdb.ss b/signal/logdb.ss
index 366dbe1..f68365f 100644
--- a/signal/logdb.ss
+++ b/signal/logdb.ss
@@ -2,12 +2,13 @@
 ;;; signal/logdb -- encrypted message log.
 ;;;
 ;;; New logs use a Jerboa-native jsqlite database image stored inside an
-;;; authenticated encrypted container. No native SQLite or SQLCipher shim is
-;;; loaded.
+;;; authenticated encrypted container. Existing SQLCipher databases are still
+;;; opened through the legacy shim so old logs do not disappear.
 
 (library (signal logdb)
   (export logdb-available?
           logdb-open logdb-close logdb-put logdb-count logdb-recent
+          logdb-migrate-legacy-to-jsqlite
           logdb-prompt-passphrase)
 
   (import (except (chezscheme)
@@ -31,6 +32,132 @@
   (defstruct jlog (path key salt db))
 
   ;; --------------------------------------------------------------------------
+  ;; Legacy SQLCipher shim. This stays for old messages-*.db files that were
+  ;; created by the previous backend.
+
+  (def *shim-load-state* (box 'untried))
+
+  (def (try-load-shim path)
+    (and (file-exists? path) (load-shared-object path)))
+
+  (def (ensure-shim-loaded!)
+    (cond
+      [(eq? (unbox *shim-load-state*) 'loaded) #t]
+      [(eq? (unbox *shim-load-state*) 'missing) #f]
+      [else
+       (let* ([bin-dir (path-directory (car (command-line)))]
+              [home (or (getenv "HOME") ".")]
+              [loaded?
+               (or (foreign-entry? "signal_log_open")
+                   (guard (e [#t #f])
+                     (or (try-load-shim "signal_log_shim.dylib")
+                         (try-load-shim "signal_log_shim.so")
+                         (try-load-shim (path-join bin-dir "signal_log_shim.dylib"))
+                         (try-load-shim (path-join bin-dir "signal_log_shim.so"))
+                         (try-load-shim (path-join home ".local" "bin"
+                                                   "signal_log_shim.dylib"))
+                         (try-load-shim (path-join home ".local" "bin"
+                                                   "signal_log_shim.so")))))])
+         (set-box! *shim-load-state* (if loaded? 'loaded 'missing))
+         loaded?)]))
+
+  (def (legacy-entry? name)
+    (or (foreign-entry? name)
+        (and (ensure-shim-loaded!) (foreign-entry? name))))
+
+  (def (legacy-available?)
+    (legacy-entry? "signal_log_open"))
+
+  (def (legacy-open path key)
+    (and (legacy-entry? "signal_log_open")
+         (let ([h ((foreign-procedure "signal_log_open" (string string) uptr)
+                   path key)])
+           (and (not (= h 0)) h))))
+
+  (def (legacy-close handle)
+    (when (and handle (legacy-entry? "signal_log_close"))
+      ((foreign-procedure "signal_log_close" (uptr) int) handle)
+      (void)))
+
+  (def (legacy-put handle account direction conversation sender
+                   timestamp kind body raw)
+    (and handle
+         (legacy-entry? "signal_log_put")
+         (= 0 ((foreign-procedure "signal_log_put"
+                  (uptr string string string string integer-64 string string string)
+                  int)
+               handle account direction conversation sender
+               timestamp kind body raw))))
+
+  (def (legacy-count handle)
+    (if (and handle (legacy-entry? "signal_log_count"))
+      ((foreign-procedure "signal_log_count" (uptr) integer-64) handle)
+      -1))
+
+  (def (legacy-recent handle limit)
+    (if (and handle
+             (legacy-entry? "signal_log_recent")
+             (legacy-entry? "signal_log_row_step")
+             (legacy-entry? "signal_log_row_text")
+             (legacy-entry? "signal_log_row_int")
+             (legacy-entry? "signal_log_row_close"))
+      (guard (e [#t '()])
+        (let ([open (foreign-procedure "signal_log_recent" (uptr integer-64) uptr)]
+              [step (foreign-procedure "signal_log_row_step" (uptr) int)]
+              [text (foreign-procedure "signal_log_row_text" (uptr int) string)]
+              [num (foreign-procedure "signal_log_row_int" (uptr int) integer-64)]
+              [close (foreign-procedure "signal_log_row_close" (uptr) int)])
+          (let ([st (open handle limit)])
+            (if (= st 0)
+              '()
+              (dynamic-wind
+                (lambda () (void))
+                (lambda ()
+                  (let loop ([acc '()])
+                    (if (= (step st) 1)
+                      (loop (cons (list (text st 0) (text st 1) (text st 2)
+                                        (num st 3) (text st 4) (text st 5))
+                                  acc))
+                      acc)))
+                (lambda () (close st)))))))
+      '()))
+
+  (def (legacy-all-rows handle)
+    (if (and handle
+             (legacy-entry? "signal_log_all_rows")
+             (legacy-entry? "signal_log_row_step")
+             (legacy-entry? "signal_log_row_text")
+             (legacy-entry? "signal_log_row_int")
+             (legacy-entry? "signal_log_row_close"))
+      (guard (e [#t '()])
+        (let ([open (foreign-procedure "signal_log_all_rows" (uptr) uptr)]
+              [step (foreign-procedure "signal_log_row_step" (uptr) int)]
+              [text (foreign-procedure "signal_log_row_text" (uptr int) string)]
+              [num (foreign-procedure "signal_log_row_int" (uptr int) integer-64)]
+              [close (foreign-procedure "signal_log_row_close" (uptr) int)])
+          (let ([st (open handle)])
+            (if (= st 0)
+              '()
+              (dynamic-wind
+                (lambda () (void))
+                (lambda ()
+                  (let loop ([acc '()])
+                    (if (= (step st) 1)
+                      (loop (cons (list (num st 0)
+                                        (text st 1)
+                                        (text st 2)
+                                        (text st 3)
+                                        (text st 4)
+                                        (num st 5)
+                                        (text st 6)
+                                        (text st 7)
+                                        (text st 8))
+                                  acc))
+                      (reverse acc))))
+                (lambda () (close st)))))))
+      '()))
+
+  ;; --------------------------------------------------------------------------
   ;; jsqlite encrypted container.
 
   (def *jlog-magic* (string->utf8 "JSQLITELOGv1\n"))
@@ -170,9 +297,16 @@
 
   (def (jlog-put log account direction conversation sender
                  timestamp kind body raw)
+    (jlog-put-row log (now-seconds) account direction conversation sender
+                  timestamp kind body raw)
+    (persist-jlog! log)
+    #t)
+
+  (def (jlog-put-row log logged-at account direction conversation sender
+                     timestamp kind body raw)
     (let ([db (jlog-db log)])
       (sqlite-exec db *insert-sql*
-                   (now-seconds)
+                   logged-at
                    (empty->null account)
                    (empty->null direction)
                    (empty->null conversation)
@@ -180,9 +314,7 @@
                    timestamp
                    (empty->null kind)
                    (empty->null body)
-                   (or raw ""))
-      (persist-jlog! log)
-      #t))
+                   (or raw ""))))
 
   (def (jlog-count log)
     (let ([rows (sqlite-query (jlog-db log) "SELECT count(*) FROM messages")])
@@ -218,10 +350,109 @@
   ;; Public API.
 
   (def (logdb-available?)
-    (log-crypto-available?))
+    (or (log-crypto-available?) (legacy-available?)))
+
+  (def (backend-env-is? name)
+    (let ([v (getenv "JERBOA_SIGNAL_LOG_BACKEND")])
+      (and v (string-ci=? v name))))
+
+  (def (wrap-legacy h)
+    (and h (make-logdb-handle 'legacy h)))
+
+  (def (logdb-migrate-legacy-to-jsqlite path key)
+    (cond
+      [(not (file-exists? path))
+       (list 'missing path)]
+      [(open-jlog path key)
+       => (lambda (h)
+            (let ([n (logdb-count h)])
+              (logdb-close h)
+              (list 'already-jsqlite path n)))]
+      [else
+       (let ([legacy (legacy-open path key)])
+         (if legacy
+           (migrate-open-legacy! path key legacy)
+           (list 'open-failed path)))]))
+
+  (def (migrate-open-legacy! path key legacy)
+    (let ([tmp (string-append path ".jsqlite-migrate")]
+          [backup (unique-backup-path path)])
+      (delete-if-exists! tmp)
+      (let* ([rows (legacy-all-rows legacy)]
+             [legacy-count (legacy-count legacy)])
+        (legacy-close legacy)
+        (if (not (= legacy-count (length rows)))
+          (begin
+            (delete-if-exists! tmp)
+            (list 'read-failed path legacy-count (length rows)))
+          (let ([new-h (new-jlog tmp key)])
+            (jlog-copy-rows! (logdb-handle-inner new-h) rows)
+            (logdb-close new-h)
+            (let ([check-h (open-jlog tmp key)])
+              (if (and check-h (= (logdb-count check-h) legacy-count))
+                (begin
+                  (logdb-close check-h)
+                  (replace-with-migrated-log! path tmp backup)
+                  (list 'migrated path backup legacy-count))
+                (begin
+                  (when check-h (logdb-close check-h))
+                  (delete-if-exists! tmp)
+                  (list 'verify-failed path legacy-count)))))))))
+
+  (def (jlog-copy-rows! log rows)
+    (let ([db (jlog-db log)])
+      (sqlite-exec db "BEGIN TRANSACTION")
+      (guard (e [#t
+                 (guard (_ [#t (void)])
+                   (sqlite-exec db "ROLLBACK"))
+                 (raise e)])
+        (for-each
+          (lambda (row)
+            (jlog-put-row log
+                          (list-ref row 0)
+                          (list-ref row 1)
+                          (list-ref row 2)
+                          (list-ref row 3)
+                          (list-ref row 4)
+                          (list-ref row 5)
+                          (list-ref row 6)
+                          (list-ref row 7)
+                          (list-ref row 8)))
+          rows)
+        (sqlite-exec db "COMMIT"))))
+
+  (def (unique-backup-path path)
+    (let loop ([n 0])
+      (let ([candidate
+             (if (= n 0)
+               (string-append path ".sqlcipher.bak")
+               (string-append path ".sqlcipher.bak."
+                              (number->string n)))])
+        (if (file-exists? candidate)
+          (loop (+ n 1))
+          candidate))))
+
+  (def (replace-with-migrated-log! path tmp backup)
+    (rename-file path backup)
+    (guard (e [#t
+               (when (file-exists? backup)
+                 (guard (_ [#t (void)])
+                   (rename-file backup path)))
+               (when (file-exists? tmp)
+                 (delete-if-exists! tmp))
+               (error 'logdb-migrate-legacy-to-jsqlite
+                      "failed to install migrated log" e)])
+      (rename-file tmp path)))
 
   (def (logdb-open path key)
-    (open-jlog path key))
+    (cond
+      [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy"))
+       (wrap-legacy (legacy-open path key))]
+      [(backend-env-is? "jsqlite")
+       (open-jlog path key)]
+      [else
+       (or (open-jlog path key)
+           (wrap-legacy (legacy-open path key)))]))
 
   (def (logdb-close handle)
     (when (logdb-handle? handle)
@@ -231,6 +462,8 @@
            (let ([log (logdb-handle-inner handle)])
              (persist-jlog! log)
              (sqlite-close (jlog-db log))))]
+        [(legacy)
+         (legacy-close (logdb-handle-inner handle))]
         [else (void)])))
 
   (def (logdb-put handle account direction conversation sender
@@ -242,6 +475,10 @@
               (jlog-put (logdb-handle-inner handle)
                         account direction conversation sender
                         timestamp kind body raw)]
+             [(legacy)
+              (legacy-put (logdb-handle-inner handle)
+                          account direction conversation sender
+                          timestamp kind body raw)]
              [else #f]))))
 
   (def (logdb-count handle)
@@ -249,6 +486,7 @@
       (guard (e [#t -1])
         (case (logdb-handle-backend handle)
           [(jsqlite) (jlog-count (logdb-handle-inner handle))]
+          [(legacy) (legacy-count (logdb-handle-inner handle))]
           [else -1]))
       -1))
 
@@ -257,6 +495,7 @@
       (guard (e [#t '()])
         (case (logdb-handle-backend handle)
           [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)]
+          [(legacy) (legacy-recent (logdb-handle-inner handle) limit)]
           [else '()]))
       '()))
 
diff --git a/signal/main.ss b/signal/main.ss
index 4585eac..fb71a36 100644
--- a/signal/main.ss
+++ b/signal/main.ss
@@ -35,7 +35,8 @@
 (import (except (jerboa prelude) meta atom?)
         (signal cmd-send)
         (signal cmd-listen)
-        (signal cmd-tui))
+        (signal cmd-tui)
+        (signal cmd-migrate-log))
 
 (def *version* "0.1.0")
 
@@ -47,6 +48,7 @@ Subcommands:
   send [-a +PHONE] RECIPIENT MESSAGE   Send a Signal message
   listen [-a +PHONE]                   Stream inbound envelopes as NDJSON
   tui [-a +PHONE]                      Start the terminal UI
+  migrate-log [-a +PHONE]              Convert legacy encrypted history log
   version                              Print version
   help                                 Print this help
 
@@ -101,6 +103,15 @@ linking once before using this tool:
                (exit 2)]
               [else
                (cmd-tui account)]))]
+         [(string=? sub "migrate-log")
+          (let-values ([(account args) (parse-account-flag rest)])
+            (cond
+              [(not (null? args))
+               (display "jerboa-signal: migrate-log takes no positional arguments\n"
+                        (current-error-port))
+               (exit 2)]
+              [else
+               (cmd-migrate-log account)]))]
          [else
           (display "jerboa-signal: unknown subcommand: " (current-error-port))
           (display sub (current-error-port))
diff --git a/tests/test-history-replay.ss b/tests/test-history-replay.ss
new file mode 100644
index 0000000..d1afb63
--- /dev/null
+++ b/tests/test-history-replay.ss
@@ -0,0 +1,76 @@
+#!chezscheme
+;;; Focused test for encrypted log replay into a fresh TUI state.
+
+(import (except (chezscheme)
+                make-hash-table hash-table?
+                sort sort!
+                printf fprintf
+                path-extension path-absolute?
+                with-input-from-string with-output-to-string
+                iota 1+ 1-
+                partition
+                make-date make-time)
+        (except (jerboa prelude) meta atom?)
+        (signal logdb)
+        (signal tui main))
+
+(def path "/tmp/jerboa-signal-history-replay-test.db")
+
+(def (delete-if-exists! p)
+  (when (file-exists? p) (delete-file p)))
+
+(def (check label pred)
+  (unless pred
+    (error 'test-history-replay label)))
+
+(def (conversation-by-id snapshots id)
+  (let loop ([xs snapshots])
+    (cond
+      [(null? xs) #f]
+      [(string=? (list-ref (car xs) 0) id) (car xs)]
+      [else (loop (cdr xs))])))
+
+(delete-if-exists! path)
+(delete-if-exists! (string-append path ".tmp"))
+
+(let ([h (logdb-open path "replay key")])
+  (check "open replay log" h)
+  (check "write inbound direct"
+         (logdb-put h "acct" "in" "direct:+15550123" "Alice"
+                    1000 "data" "hello" "{\"type\":\"in\"}"))
+  (check "write outbound direct"
+         (logdb-put h "acct" "out" "direct:+15550123" "You"
+                    1001 "data" "reply" "{\"type\":\"out\"}"))
+  (check "write group edit"
+         (logdb-put h "acct" "in" "group:GROUP1" "Bob"
+                    1002 "edit" "[edited] group hello"
+                    "{\"type\":\"edit\"}"))
+  (logdb-close h))
+
+(let ([h (logdb-open path "replay key")])
+  (check "reopen replay log" h)
+  (let* ([snapshot (history-replay-snapshot h)]
+         [count (list-ref snapshot 0)]
+         [convs (list-ref snapshot 1)]
+         [direct (conversation-by-id convs "direct:+15550123")]
+         [group (conversation-by-id convs "group:GROUP1")])
+    (check "restores all displayable rows" (= count 3))
+    (check "restores direct conversation" direct)
+    (check "restores direct title from sender"
+           (string=? (list-ref direct 1) "Alice"))
+    (check "restores direct messages chronologically"
+           (equal? (list-ref direct 4)
+                   (list (list 'in "Alice" "hello" 1000 'data #f)
+                         (list 'out "You" "reply" 1001 'data #f))))
+    (check "restores group conversation" group)
+    (check "preserves replayed message kind"
+           (equal? (list-ref group 4)
+                   (list (list 'in "Bob" "[edited] group hello"
+                               1002 'edit #f)))))
+  (logdb-close h))
+
+(delete-if-exists! path)
+(delete-if-exists! (string-append path ".tmp"))
+
+(display "history replay ok")
+(newline)