* feat: Persist chat history in backend storage * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address chat tombstone batching review * fix: update desktop auth routes stub * chat db settings storage * chat db settings routes * chat db settings client * chat db settings store * chat db settings wiring * chat db history storage * chat db settings migration * chat db settings fallback * chat db container metadata * chat db legacy migration fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chat ci auth background reads * chat auth storage fixes * chat migration final fixes * chat export batch message lookup * chat history review fixes * chat prune sync fix * chat settings hydration retry * gate settings persistence * Scope chat-history rows by subject; fix hijack, clear-confirm, hydrate race Backend storage and routes: - chat_threads / chat_messages / chat_settings carry a NOT NULL subject column with composite PRIMARY KEY (id, subject). Two authenticated identities can no longer see or wipe each other's data. - Pre-existing rows on an existing studio.db migrate under sentinel subject __legacy_unscoped__ via rename + rebuild + copy; single-user installs see no behavior change. - ON CONFLICT(id, subject) DO UPDATE ... WHERE chat_messages.thread_id = excluded.thread_id refuses cross-thread re-parenting via upsert. upsert_chat_message + sync_chat_messages now raise ChatMessageThreadMismatch which the routes map to HTTP 409. - replace_thread_messages rejects body messages whose threadId does not match the URL thread (HTTP 400) instead of silently rewriting them. - DELETE /api/chat requires ?confirm=true, returns row count, logs the subject and count. - upsert_chat_settings_merge does read + deep-merge + write inside a single BEGIN IMMEDIATE so concurrent writers no longer drop each other's updates. The route delegates to this helper. - New POST /api/chat/messages:batch returns {thread_id -> messages[]} for many threads in one HTTP call. Subject-scoped. Unknown ids return empty lists instead of 404 so the sidebar/search caller can rebuild atomically. Frontend: - chat-runtime-store: hydrate-failure catch sets settingsHydrated:true so a transient backend blip no longer permanently disables persistence. setParams bumps inferenceParamMutationVersions unconditionally so a slow hydration response cannot clobber a pre-hydrate user edit. saveSettingsPatch replaces the serial chain with a debounced pendingPatch + deep merge; flush on beforeunload. - chat-history-storage: clearStoredChats returns ClearStoredChatsResult distinguishing backend / legacy / both outcomes. listStoredChatThreadsWithMessages uses the batched fetch (one HTTP call) instead of Promise.all per-thread; legacy Dexie fallback only fires when the batch result is empty. - chat-api: batchListChatMessages with graceful 404 / 405 fallback to per-thread listChatMessages for older servers. - chat-thread-tombstones: store {id, deletedAt} tuples with 90-day GC and a 5000-entry cap so localStorage stays bounded. Back-compat reads pre-fix plain strings. Adds removeChatThreadTombstones (rollback) and clearAllChatThreadTombstones (post-legacy-purge clean-up). - use-chat-sidebar-items: deleteChatItem tombstones synchronously BEFORE the backend round-trip and rolls back on failure (restores pre-PR optimistic UX). 300 ms trailing debounce on CHAT_HISTORY_UPDATED_EVENT plus requestSeq guard so stream-time event bursts produce at most one fetch per quiet window. Tests: - studio/backend/tests/pr5272_sim/ adds 64 regression tests covering schema migration from pre-fix shape, subject scoping, cross-thread hijack, bulk-replace mismatch, clear-confirm, concurrent settings, unicode + 2MB content + SQL-injection-safe binding, chunking boundary at 900 and 901 ids, batched endpoint (multi-subject + 1200 ids + per-thread order), and grep contracts for the frontend patches. test_chat_history_storage.py updated to pass subject. Verified locally on Linux + macOS + Windows GitHub Actions runners (staging fork): 64 pass + 2 from the PR's own backend test on all three OSes. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop subject scoping and clear-confirm gate (Studio is single-user) Per maintainer feedback: subject scoping, cross-thread message hijack guard, and DELETE /api/chat ?confirm=true gate are unnecessary because Studio is intentionally single-user (the client already shows a confirm dialog before clear-all). This commit reverts those backend changes and keeps only the non-multi-user pieces from the earlier fix commit: - studio_db.py: restored to pre-fix shape; adds upsert_chat_settings_merge which does atomic read + deep-merge + write under BEGIN IMMEDIATE so two concurrent slider drags cannot drop one another's updates. - routes/chat_history.py: restored; put_settings now calls the atomic merge instead of doing the read-merge-write across three separate connections. Adds POST /api/chat/messages:batch to collapse the sidebar/search rebuild from N round-trips to 1. - frontend/api/chat-api.ts: align batchListChatMessages request and response keys with the backend (threadIds / messagesByThreadId). - tests/test_chat_history_storage.py: add atomic-merge concurrency test, deep-merge nested-key test, and 901-id chunking-boundary test. - Drop the pr5272_sim test directory (those tests covered the reverted subject-scoping/hijack/confirm behavior). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix sidebar delete crash, keepalive on settings beforeunload flush, search rebuild race Two correctness bugs and one perf race surfaced by a fresh code review of the prior fix commit: - chat-api.ts: notifyChatHistoryUpdated was declared as a non-exported function, but use-chat-sidebar-items.ts imports it. The import would fail tsc with TS2305 and at runtime the optimistic-delete and delete-failure rollback paths would both throw. - chat-runtime-store.ts + chat-settings-api.ts + chat-settings-storage.ts: the beforeunload settings flush is now actually keepalive. Without it the browser cancels the in-flight PUT on tab close, so the last slider drag is silently dropped (which is exactly the case the debounce+beforeunload combination was meant to protect against). - use-chat-search-index.ts: rebuilds now coalesce with a 300ms trailing debounce and discard out-of-order responses via a requestSeq guard. Matches the sibling pattern in use-chat-sidebar-items.ts so two rapid CHAT_HISTORY_UPDATED_EVENTs (run-start + run-end save during a turn) cannot land with stale data winning. - chat-thread-tombstones.ts: drop dead clearAllChatThreadTombstones with no call sites; Dexie is never wiped so the function has no use. * fix(studio): protect chat persistence writes * fix(studio): align chat history clear semantics * fix(studio): show partial chat clear feedback * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): preserve chat persistence fallbacks * fix(studio): harden chat thread persistence checks * Preserve chat message timestamps * Gate chat stream on history save * Make chat thread backfill best effort * Avoid chat message 404 probe * Tighten chat legacy fallbacks * chat: server-side ledger so legacy Dexie import is recoverable The boolean localStorage sentinel (unsloth_chat_legacy_imported_to_studio_db) made importLegacyChatsIfNeeded non-recoverable: deleting studio.db while the browser keeps the flag silently hides every legacy Dexie thread from the sidebar (verified by the 3-GPU validation probe; matches the third review comment on PR #5272). Same trap fires for browser-profile sync to a fresh machine and any other path that wipes studio.db while keeping IndexedDB. Source of truth moves into studio.db itself via a new chat_legacy_import_log table keyed by legacy thread id. The ledger disappears together with studio.db, so the next launch re-runs the import from whatever Dexie still holds. localStorage stays as a per-session perf hint only. Performance, all bounded by the three new fast-paths before any backend work: A) localStorage hint says "imported earlier in this session" -- 0 network, ~0 ms. Covers the warm sidebar mount. B) indexedDB.databases() reports no "unsloth-chat" DB -- 0 network, ~1 ms. Covers every new user who never had the old browser-only Studio (the common case after launch). C) db.threads.count() + db.messages.count() are both 0 -- 0 network, ~5 ms. Covers returning users who migrated long ago and Dexie was never repopulated. Only when all three miss does the code talk to the backend (GET /api/chat/import-ledger -> diff vs Dexie -> existing import path -> POST /api/chat/import-ledger to record what was just imported). Per-thread tracking is enough because Dexie is read-only after this PR; a thread's message set does not grow. Backend deployments that predate the import-ledger routes are handled transparently: the client treats 404/405 as an empty ledger and re-runs the (idempotent via UPSERT) import on next launch. Changes: - storage/studio_db.py: new chat_legacy_import_log table (WITHOUT ROWID, PK on legacy_thread_id) + list_chat_legacy_import_log() + record_chat_legacy_import_log() (idempotent batch UPSERT). - routes/chat_history.py: GET + POST /api/chat/import-ledger with the obvious request/response models. - frontend api/chat-api.ts: listChatImportLedger() (returns a Set for O(1) diff) + recordChatImportLedger(), both with 404/405 fallback. - frontend utils/chat-history-storage.ts: importLegacyChatsIfNeeded gains three fast-paths, ledger fetch on the slow path, and writes the ledger after a successful import. The localStorage helper is unchanged on the surface; it just stops being authoritative. - tests: 5 new test_legacy_import_log_* cases (empty default, record + list round-trip, idempotency, input dedup, empty/null ignore). All 9 pre-existing tests still pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the legacy-import recovery actually recoverable The previous commit added a server-side ledger to make Dexie -> studio.db import recoverable after a studio.db wipe, but the localStorage perf hint still short-circuited the import gate before the ledger was ever consulted. After a wipe, the hint stayed "true" and the bulk re-import never ran -- the ledger sat empty and only the per-thread lazy materialize-on-continue path restored data. Changes: - Remove the localStorage short-circuit from importLegacyChatsIfNeeded so the ledger is checked on every fresh tab. legacyChatImportPromise keeps the per-session cache; the hint now only matters for the listing paths. - Batch the slow path: one db.messages.where().anyOf().toArray() and one batchListChatMessages() instead of 2N round-trips. At 1k threads this drops a multi-second blocking import to a single request pair. - recordChatImportLedger returns {accepted, inserted, supported}. The localStorage hint is only flipped when supported is true, so old backends (404 / 405 / 501) no longer permanently poison recovery. - Ledger backfill: threads already present in chat_threads but missing from the ledger now get added too, so old-FE-then-new-FE deployments don't redo the diff every launch. - Backend response field renamed recorded -> {accepted, inserted}. accepted is the deduped non-empty input count; inserted is the rows actually new (via INSERT ... RETURNING). Bounded by Field(max_length= 10_000) on the request payload. - Storage helpers renamed: chat_legacy_import_log -> chat_legacy_imports, record_* -> upsert_* to match the existing noun/verb conventions. - DEXIE_DB_NAME exported from db.ts; duplicate constant in chat-history-storage.ts removed. - 3 new route-level tests for /api/chat/import-ledger covering the round-trip, the (accepted, inserted) split, and the 10k payload cap. All 18 chat-history tests pass. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: shine1i <wasimysdev@gmail.com> Co-authored-by: danielhanchen <michaelhan2050@gmail.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
308 lines
9.9 KiB
Python
308 lines
9.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from storage import studio_db
|
|
|
|
|
|
def _reset_studio_db(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
|
monkeypatch.setattr(studio_db, "_schema_ready", False)
|
|
|
|
|
|
def _thread(thread_id: str = "thread-1") -> dict:
|
|
return {
|
|
"id": thread_id,
|
|
"title": "Test Chat",
|
|
"modelType": "base",
|
|
"modelId": "test-model",
|
|
"pairId": None,
|
|
"archived": False,
|
|
"createdAt": 1_700_000_000_000,
|
|
}
|
|
|
|
|
|
def _message(
|
|
message_id: str,
|
|
created_at: int,
|
|
content: str,
|
|
thread_id: str = "thread-1",
|
|
) -> dict:
|
|
return {
|
|
"id": message_id,
|
|
"threadId": thread_id,
|
|
"parentId": None,
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": content}],
|
|
"createdAt": created_at,
|
|
}
|
|
|
|
|
|
def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_thread(_thread())
|
|
studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[
|
|
_message("msg-1", 1, "keep me"),
|
|
_message("msg-2", 2, "old text"),
|
|
],
|
|
prune_missing = True,
|
|
)
|
|
|
|
messages = studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[_message("msg-2", 2, "updated text")],
|
|
)
|
|
|
|
by_id = {message["id"]: message for message in messages}
|
|
assert set(by_id) == {"msg-1", "msg-2"}
|
|
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
|
|
|
|
|
|
def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_thread(_thread())
|
|
studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[
|
|
_message("msg-1", 1, "delete me"),
|
|
_message("msg-2", 2, "keep me"),
|
|
],
|
|
)
|
|
|
|
messages = studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[_message("msg-2", 2, "keep me")],
|
|
prune_missing = True,
|
|
)
|
|
|
|
assert [message["id"] for message in messages] == ["msg-2"]
|
|
|
|
|
|
def test_upsert_chat_message_rejects_cross_thread_id_conflict(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_thread(_thread("thread-1"))
|
|
studio_db.upsert_chat_thread(_thread("thread-2"))
|
|
studio_db.upsert_chat_message(_message("msg-1", 1, "original", "thread-1"))
|
|
|
|
with pytest.raises(studio_db.ChatMessageConflictError):
|
|
studio_db.upsert_chat_message(_message("msg-1", 2, "moved", "thread-2"))
|
|
|
|
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["msg-1"]
|
|
assert studio_db.list_chat_messages("thread-2") == []
|
|
|
|
|
|
def test_sync_chat_messages_detects_conflict_before_prune(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_thread(_thread("thread-1"))
|
|
studio_db.upsert_chat_thread(_thread("thread-2"))
|
|
studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[_message("keep-me", 1, "keep", "thread-1")],
|
|
)
|
|
studio_db.upsert_chat_message(_message("conflict", 2, "other", "thread-2"))
|
|
|
|
with pytest.raises(studio_db.ChatMessageConflictError):
|
|
studio_db.sync_chat_messages(
|
|
"thread-1",
|
|
[_message("conflict", 3, "bad", "thread-1")],
|
|
prune_missing = True,
|
|
)
|
|
|
|
assert [m["id"] for m in studio_db.list_chat_messages("thread-1")] == ["keep-me"]
|
|
assert [m["id"] for m in studio_db.list_chat_messages("thread-2")] == ["conflict"]
|
|
|
|
|
|
def test_settings_merge_atomic_under_concurrency(tmp_path, monkeypatch):
|
|
"""Two threads writing distinct keys must not drop each other's update."""
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_settings_merge({"inferenceParams": {}})
|
|
|
|
barrier = threading.Barrier(2)
|
|
|
|
def writer(key: str, value: float) -> None:
|
|
barrier.wait()
|
|
studio_db.upsert_chat_settings_merge({"inferenceParams": {key: value}})
|
|
|
|
t1 = threading.Thread(target = writer, args = ("temperature", 0.7))
|
|
t2 = threading.Thread(target = writer, args = ("topP", 0.9))
|
|
t1.start()
|
|
t2.start()
|
|
t1.join()
|
|
t2.join()
|
|
|
|
merged = studio_db.list_chat_settings()["inferenceParams"]
|
|
assert merged.get("temperature") == 0.7
|
|
assert merged.get("topP") == 0.9
|
|
|
|
|
|
def test_settings_merge_preserves_nested_keys(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_settings_merge(
|
|
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
|
|
)
|
|
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
|
|
|
|
params = studio_db.list_chat_settings()["inferenceParams"]
|
|
assert params == {"temperature": 0.9, "topP": 0.8}
|
|
|
|
|
|
def test_settings_merge_quarantines_corrupt_json_and_rejects_partial_patch(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_settings_merge(
|
|
{"inferenceParams": {"temperature": 0.5, "topP": 0.8}}
|
|
)
|
|
conn = studio_db.get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
|
|
('{"temperature": 0.5', "inferenceParams"),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
with pytest.raises(studio_db.CorruptSettingsError):
|
|
studio_db.upsert_chat_settings_merge({"inferenceParams": {"temperature": 0.9}})
|
|
|
|
conn = studio_db.get_connection()
|
|
try:
|
|
quarantined = conn.execute(
|
|
"SELECT key, value_json, reason FROM chat_settings_quarantine"
|
|
).fetchall()
|
|
remaining = conn.execute(
|
|
"SELECT key FROM chat_settings WHERE key = ?",
|
|
("inferenceParams",),
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
assert [row["key"] for row in quarantined] == ["inferenceParams"]
|
|
assert quarantined[0]["reason"] == "json_decode_error"
|
|
assert remaining == []
|
|
|
|
|
|
def test_settings_merge_replaces_corrupt_scalar_after_quarantine(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
studio_db.upsert_chat_settings_merge({"autoTitle": False})
|
|
conn = studio_db.get_connection()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE chat_settings SET value_json = ? WHERE key = ?",
|
|
("not-json", "autoTitle"),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
settings = studio_db.upsert_chat_settings_merge({"autoTitle": True})
|
|
|
|
assert settings["autoTitle"] is True
|
|
conn = studio_db.get_connection()
|
|
try:
|
|
quarantined = conn.execute(
|
|
"SELECT key, reason FROM chat_settings_quarantine"
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
assert [(row["key"], row["reason"]) for row in quarantined] == [
|
|
("autoTitle", "json_decode_error")
|
|
]
|
|
|
|
|
|
def test_list_chat_messages_for_threads_chunks_over_900_ids(tmp_path, monkeypatch):
|
|
"""SQLite host-parameter limit is 999 on older builds; chunk at 900."""
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
n = 901
|
|
for i in range(n):
|
|
studio_db.upsert_chat_thread(
|
|
{
|
|
"id": f"t-{i}",
|
|
"title": "T",
|
|
"modelType": "base",
|
|
"modelId": "m",
|
|
"pairId": None,
|
|
"archived": False,
|
|
"createdAt": 1_700_000_000_000 + i,
|
|
}
|
|
)
|
|
studio_db.upsert_chat_message(
|
|
{
|
|
"id": f"m-{i}",
|
|
"threadId": f"t-{i}",
|
|
"parentId": None,
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "hi"}],
|
|
"createdAt": 1_700_000_000_000 + i,
|
|
}
|
|
)
|
|
out = studio_db.list_chat_messages_for_threads([f"t-{i}" for i in range(n)])
|
|
assert len(out) == n
|
|
assert {m["threadId"] for m in out} == {f"t-{i}" for i in range(n)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy Dexie import ledger
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_legacy_imports_empty_by_default(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
assert studio_db.list_chat_legacy_imports() == []
|
|
|
|
|
|
def test_legacy_imports_records_and_lists(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
accepted, inserted = studio_db.upsert_chat_legacy_imports(
|
|
["legacy-a", "legacy-b", "legacy-c"],
|
|
)
|
|
assert accepted == 3
|
|
assert inserted == 3
|
|
assert set(studio_db.list_chat_legacy_imports()) == {
|
|
"legacy-a",
|
|
"legacy-b",
|
|
"legacy-c",
|
|
}
|
|
|
|
|
|
def test_legacy_imports_is_idempotent(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
accepted1, inserted1 = studio_db.upsert_chat_legacy_imports(
|
|
["legacy-a", "legacy-b"],
|
|
)
|
|
accepted2, inserted2 = studio_db.upsert_chat_legacy_imports(
|
|
["legacy-b", "legacy-c"],
|
|
)
|
|
assert (accepted1, inserted1) == (2, 2)
|
|
# legacy-b is already in the ledger, only legacy-c is genuinely new.
|
|
assert (accepted2, inserted2) == (2, 1)
|
|
assert set(studio_db.list_chat_legacy_imports()) == {
|
|
"legacy-a",
|
|
"legacy-b",
|
|
"legacy-c",
|
|
}
|
|
|
|
|
|
def test_legacy_imports_dedups_input(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
accepted, inserted = studio_db.upsert_chat_legacy_imports(
|
|
["x", "x", "y", "x"],
|
|
)
|
|
# accepted is the deduped non-empty input size; inserted is the rows
|
|
# actually new in the ledger after ON CONFLICT DO NOTHING.
|
|
assert accepted == 2
|
|
assert inserted == 2
|
|
assert set(studio_db.list_chat_legacy_imports()) == {"x", "y"}
|
|
|
|
|
|
def test_legacy_imports_ignores_empty(tmp_path, monkeypatch):
|
|
_reset_studio_db(tmp_path, monkeypatch)
|
|
assert studio_db.upsert_chat_legacy_imports([]) == (0, 0)
|
|
assert studio_db.upsert_chat_legacy_imports(["", None]) == (0, 0) # type: ignore[list-item]
|
|
assert studio_db.list_chat_legacy_imports() == []
|