Spaces:
Running
Running
File size: 9,324 Bytes
cfd4ffb 2931047 cfceaf9 2931047 cfceaf9 cfd4ffb 2931047 cfceaf9 cfd4ffb cfceaf9 cfd4ffb 2931047 cfceaf9 cfd4ffb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | #!/usr/bin/env python3
"""Verify the v1 -> v2 migration upgrades an existing database without data loss."""
import asyncio
import os
import shutil
import sqlite3
import sys
import tempfile
WORK = tempfile.mkdtemp(prefix="ichat_migrate_test_")
DB = os.path.join(WORK, "infinitychat.db")
# ------------------------------------------------------------------
# 1. Build a database exactly as the OLD app (v1) created it
# ------------------------------------------------------------------
con = sqlite3.connect(DB)
con.executescript("""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
avatar_path TEXT,
token TEXT UNIQUE,
created_at INTEGER DEFAULT (strftime('%s','now')),
last_seen INTEGER DEFAULT (strftime('%s','now')),
status TEXT DEFAULT 'offline'
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL,
encrypted_content TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
reply_to_id INTEGER,
is_edited INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
file_path TEXT,
file_type TEXT,
file_name TEXT,
file_size INTEGER DEFAULT 0,
FOREIGN KEY(sender_id) REFERENCES users(id)
);
CREATE TABLE read_receipts (
user_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
read_at INTEGER DEFAULT (strftime('%s','now')),
PRIMARY KEY (user_id, message_id)
);
CREATE INDEX idx_messages_sender_time ON messages(sender_id, timestamp_ms);
CREATE INDEX idx_messages_time_id ON messages(timestamp_ms, id);
CREATE INDEX idx_messages_not_deleted ON messages(is_deleted, id);
CREATE INDEX idx_receipts_user_msg ON read_receipts(user_id, message_id);
""")
# legacy data: 2 users, 3 messages, receipts in *seconds*
con.executemany(
"INSERT INTO users (username, display_name, password_hash, salt, token, status) VALUES (?,?,?,?,?,?)",
[
("alice", "Alice", "hash1", "salt1", "tok_alice", "online"),
("bob", "Bobby", "hash2", "salt2", "tok_bob", "offline"),
]
)
# bob sends message (encrypted_content is opaque to migration)
con.execute(
"INSERT INTO messages (sender_id, encrypted_content, timestamp_ms) VALUES (2, 'CIPHERTEXT_HELLO', 1700000000000)"
)
con.execute(
"INSERT INTO messages (sender_id, encrypted_content, timestamp_ms) VALUES (1, 'CIPHERTEXT_WORLD', 1700000001000)"
)
# alice read bob's message at second-precision epoch 1700000002
con.execute(
"INSERT INTO read_receipts (user_id, message_id, read_at) VALUES (1, 1, 1700000002)"
)
con.commit()
con.close()
before_rows = {}
def dump(label):
c = sqlite3.connect(DB)
c.row_factory = sqlite3.Row
tables = [r["name"] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
print(f"--- {label} tables:", tables)
for t in ("users", "messages", "read_receipts", "conversations", "conversation_members"):
try:
rows = [dict(r) for r in c.execute(f"SELECT * FROM {t} ORDER BY 1")]
print(f" {t}: {len(rows)} row(s)")
for r in rows[:6]:
print(" ", r)
except sqlite3.Error as e:
print(f" {t}: ERROR {e}")
c.close()
dump("BEFORE MIGRATION")
before_messages = sqlite3.connect(DB).execute("SELECT COUNT(*) FROM messages").fetchone()[0]
before_users = sqlite3.connect(DB).execute("SELECT COUNT(*) FROM users").fetchone()[0]
# ------------------------------------------------------------------
# 2. Run the v2 app initializer against the legacy DB
# ------------------------------------------------------------------
os.environ["DATABASE_URL"] = DB
os.environ["HF_TOKEN"] = "" # offline mode
os.environ["SECRET_KEY"] = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" # 32 bytes
os.environ["FILE_ENCRYPTION_KEY"] = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
os.environ["TEMP_DIR"] = os.path.join(WORK, "tmp")
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO_ROOT)
# import storage_handler module needs clean reload w/ env; import app fresh
import importlib
import storage_handler
importlib.reload(storage_handler)
import app as app_module
importlib.reload(app_module)
asyncio.run(app_module.init_database())
print("\ninit_database() completed without data loss errors")
dump("AFTER MIGRATION")
# ------------------------------------------------------------------
# 3. Assertions
# ------------------------------------------------------------------
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
failures = []
# data preserved
n_msgs = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
n_users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n_msgs != before_messages:
failures.append(f"messages lost: {before_messages} -> {n_msgs}")
if n_users != before_users:
failures.append(f"users lost: {before_users} -> {n_users}")
# new schema pieces exist
cols = {r["name"] for r in con.execute("PRAGMA table_info(messages)")}
for need in ("conversation_id", "client_id"):
if need not in cols:
failures.append(f"messages missing column {need}")
convs = con.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
if convs != 1:
failures.append(f"expected seeded global conversation, found {convs}")
conv = con.execute("SELECT * FROM conversations WHERE id=1").fetchone()
if conv["type"] != "global":
failures.append("conversation 1 is not global")
# legacy messages backfilled into the global conversation
bad_conv = con.execute(
"SELECT COUNT(*) FROM messages WHERE conversation_id != 1 OR conversation_id IS NULL"
).fetchone()[0]
if bad_conv:
failures.append(f"{bad_conv} messages not in global conversation")
# receipts upgraded from seconds to milliseconds
rc = con.execute("SELECT read_at FROM read_receipts WHERE user_id=1 AND message_id=1").fetchone()
if not rc or rc["read_at"] != 1700000002000:
failures.append(f"read_at not converted to ms: {rc and rc['read_at']}")
# encrypted content untouched
msg = con.execute("SELECT encrypted_content FROM messages WHERE id=1").fetchone()
if msg["encrypted_content"] != "CIPHERTEXT_HELLO":
failures.append("message content was modified!")
# indexes exist
idx = {r["name"] for r in con.execute("PRAGMA index_list(messages)")}
for need in ("idx_messages_client_dedupe", "idx_messages_conversation"):
if need not in idx:
failures.append(f"missing index {need}")
# Pending invites must survive a restart (they are not auto-accepted by the
# migration, which would defeat the invite requirement for DMs/groups).
con.execute(
"INSERT INTO conversations (type, title, created_by, is_group) VALUES ('dm', '', 1, 1)"
)
gid = con.execute("SELECT last_insert_rowid()").fetchone()[0]
con.execute(
"INSERT INTO conversation_members (conversation_id, user_id, joined_at, status, role) "
"VALUES (?, ?, strftime('%s','now'), 'pending', 'member')",
(gid, 2)
)
# Regression: on restart the startup backfill used to rewrite every DM/group
# message back into Global (id 1) because its NOT IN list only contained id 1.
# Put a real message in this non-global conversation and ensure it survives.
con.execute(
"INSERT INTO messages (sender_id, encrypted_content, timestamp_ms, conversation_id) "
"VALUES (1, 'CIPHERTEXT_DM', 1700000003000, ?)",
(gid,)
)
con.commit()
convs_before_second = con.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
msgs_before_second = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
con.close()
# ------------------------------------------------------------------
# 4. Idempotency: run migration a second time (like a second deploy)
# ------------------------------------------------------------------
asyncio.run(app_module.init_database())
con = sqlite3.connect(DB)
n2 = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
c2 = con.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
u2 = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
rc2 = con.execute("SELECT read_at FROM read_receipts WHERE user_id=1 AND message_id=1").fetchone()[0]
pend = con.execute(
"SELECT status FROM conversation_members WHERE conversation_id=? AND user_id=2", (gid,)
).fetchone()
dm_msg = con.execute(
"SELECT conversation_id FROM messages WHERE encrypted_content='CIPHERTEXT_DM'"
).fetchone()
con.close()
if n2 != msgs_before_second or c2 != convs_before_second or u2 != n_users:
failures.append(f"second run duplicated/lost data: msgs {msgs_before_second}->{n2}, convs {convs_before_second}->{c2}, users {n_users}->{u2}")
if rc2 != 1700000002000:
failures.append(f"read_at re-multiplied on second run: {rc2}")
if not pend or pend[0] != "pending":
failures.append(f"pending invite was not preserved across restart: {pend}")
if not dm_msg or dm_msg[0] != gid:
failures.append(f"private-chat message was moved to Global on restart: {dm_msg}")
print("\n==== RESULT ====")
if failures:
print("FAILURES:")
for f in failures:
print(" ✗", f)
sys.exit(1)
print("✓ Migration upgraded v1 DB in place with zero data loss")
print("✓ Idempotent (second run changes nothing)")
shutil.rmtree(WORK, ignore_errors=True)
sys.exit(0) |