mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(ui): preserve other chat transcripts when deleting a session (#128109)
* fix(ui): preserve pending transcripts across scoped session deletes * fix(ui): type IndexedDB snapshot regression fixture
This commit is contained in:
committed by
GitHub
parent
87d49a3dca
commit
8d2024542e
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { IDBFactory, IDBObjectStore } from "fake-indexeddb";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
@@ -314,6 +314,84 @@ describe("persistent chat session snapshots", () => {
|
||||
expect(await reader.read("agent:main:retained")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves an unrelated in-flight transcript while deleting another session", async () => {
|
||||
const writer = new SessionSnapshotStore();
|
||||
writer.connect();
|
||||
try {
|
||||
const retainedSession = "agent:main:retained";
|
||||
writer.write(retainedSession, snapshot("important transcript"));
|
||||
|
||||
await Promise.all([writer.flush(), writer.delete("agent:main:deleted")]);
|
||||
|
||||
expect(await new SessionSnapshotStore().read(retainedSession)).toEqual(
|
||||
snapshot("important transcript"),
|
||||
);
|
||||
expect(writer.readSavedAt(retainedSession)).not.toBeNull();
|
||||
} finally {
|
||||
writer.disconnect();
|
||||
await writer.whenIdle();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["session", "all"])(
|
||||
"does not restore an in-flight transcript after %s invalidation",
|
||||
async (scope) => {
|
||||
const sessionKey = "agent:main:deleted";
|
||||
const writer = new SessionSnapshotStore();
|
||||
writer.connect();
|
||||
try {
|
||||
writer.write(sessionKey, snapshot("deleted transcript"));
|
||||
|
||||
await Promise.all([
|
||||
writer.flush(),
|
||||
scope === "session" ? writer.delete(sessionKey) : clearStoredChatSnapshots(),
|
||||
]);
|
||||
|
||||
expect(await new SessionSnapshotStore().read(sessionKey)).toBeNull();
|
||||
expect(writer.readSavedAt(sessionKey)).toBeNull();
|
||||
} finally {
|
||||
writer.disconnect();
|
||||
await writer.whenIdle();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not restore deleted metadata while seeding the snapshot index", async () => {
|
||||
const sessionKey = "agent:main:deleted-during-seed";
|
||||
const writer = new SessionSnapshotStore();
|
||||
writer.write(sessionKey, snapshot("deleted transcript"));
|
||||
await writer.flush();
|
||||
|
||||
const reader = new SessionSnapshotStore();
|
||||
reader.connect();
|
||||
try {
|
||||
let deletion: Promise<void> | undefined;
|
||||
const originalGetAll = Reflect.get(
|
||||
IDBObjectStore.prototype,
|
||||
"getAll",
|
||||
) as IDBObjectStore["getAll"];
|
||||
vi.spyOn(IDBObjectStore.prototype, "getAll").mockImplementationOnce(function (
|
||||
this: IDBObjectStore,
|
||||
...args
|
||||
) {
|
||||
const request = originalGetAll.apply(this, args);
|
||||
request.addEventListener("success", () => {
|
||||
deletion = writer.delete(sessionKey);
|
||||
});
|
||||
return request;
|
||||
});
|
||||
|
||||
await reader.loadSavedAtIndex();
|
||||
expect(deletion).toBeDefined();
|
||||
await deletion;
|
||||
|
||||
expect(reader.readSavedAt(sessionKey)).toBeNull();
|
||||
} finally {
|
||||
reader.disconnect();
|
||||
await reader.whenIdle();
|
||||
}
|
||||
});
|
||||
|
||||
it("upgrades a version one database before deleting an invalidated snapshot", async () => {
|
||||
const sessionKey = "agent:main:legacy-delete";
|
||||
await putVersionOneRecord(sessionKey);
|
||||
|
||||
@@ -338,8 +338,7 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
}
|
||||
|
||||
async delete(sessionKey: string): Promise<void> {
|
||||
this.pending.delete(sessionKey);
|
||||
this.savedAtBySession.delete(sessionKey);
|
||||
this.forget(sessionKey);
|
||||
await deleteStoredChatSnapshot(sessionKey);
|
||||
}
|
||||
|
||||
@@ -356,6 +355,9 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
this.writeTimer = null;
|
||||
}
|
||||
const pending = [...this.pending.entries()];
|
||||
const pendingRevisions = new Map(
|
||||
pending.map(([sessionKey]) => [sessionKey, this.revisions.get(sessionKey) ?? 0]),
|
||||
);
|
||||
this.pending.clear();
|
||||
const records: SessionSnapshotRecord[] = [];
|
||||
for (const [sessionKey, state] of pending) {
|
||||
@@ -368,7 +370,11 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
}
|
||||
const generation = snapshotStoreGeneration;
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
const evicted = await writeSnapshotRecords(records, generation);
|
||||
const currentRecords = records.filter(
|
||||
({ sessionKey }) =>
|
||||
pendingRevisions.get(sessionKey) === (this.revisions.get(sessionKey) ?? 0),
|
||||
);
|
||||
const evicted = await writeSnapshotRecords(currentRecords, generation);
|
||||
if (evicted === null) {
|
||||
this.resetSavedAtIndex();
|
||||
return;
|
||||
@@ -416,6 +422,7 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
|
||||
private async seedSavedAtIndex(): Promise<void> {
|
||||
const generation = snapshotStoreGeneration;
|
||||
const revisions = new Map(this.revisions);
|
||||
const records = await readSnapshotRecords();
|
||||
if (generation !== snapshotStoreGeneration) {
|
||||
return;
|
||||
@@ -425,6 +432,11 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
return;
|
||||
}
|
||||
for (const record of records) {
|
||||
if (
|
||||
(revisions.get(record.sessionKey) ?? 0) !== (this.revisions.get(record.sessionKey) ?? 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = this.savedAtBySession.get(record.sessionKey) ?? 0;
|
||||
this.savedAtBySession.set(record.sessionKey, Math.max(current, record.savedAt));
|
||||
}
|
||||
@@ -439,7 +451,10 @@ export class SessionSnapshotStore implements ChatCacheObserver {
|
||||
}
|
||||
|
||||
subscribeSnapshotInvalidation(async ({ sessionKey }) => {
|
||||
snapshotStoreGeneration += 1;
|
||||
// Scoped deletes fence only their session; whole-cache clears retire every pending operation.
|
||||
if (!sessionKey) {
|
||||
snapshotStoreGeneration += 1;
|
||||
}
|
||||
for (const store of activeStores) {
|
||||
if (sessionKey) {
|
||||
store.forget(sessionKey);
|
||||
|
||||
Reference in New Issue
Block a user