mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(document): coalesce autosave history versions into 10-minute windows (#15716)
* ✨ feat(document): coalesce autosave history versions into 10-minute windows * ✨ feat(document): break autosave history window on new page load session
This commit is contained in:
@@ -36,6 +36,7 @@ export const compareDocumentHistoryItemsInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const updateDocumentInputSchema = z.object({
|
||||
breakAutosaveWindow: z.boolean().optional(),
|
||||
content: z.string().optional(),
|
||||
editorData: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
@@ -124,6 +125,7 @@ export interface CompareHistoryItemsInput {
|
||||
}
|
||||
|
||||
export interface UpdateDocumentInput {
|
||||
breakAutosaveWindow?: boolean;
|
||||
content?: string;
|
||||
editorData?: string;
|
||||
fileType?: string;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { documentHistories, documents, files, users } from '@lobechat/database/s
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { DOCUMENT_HISTORY_SOURCE_LIMITS } from '@/const/documentHistory';
|
||||
import {
|
||||
DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS,
|
||||
DOCUMENT_HISTORY_SOURCE_LIMITS,
|
||||
} from '@/const/documentHistory';
|
||||
import { getTestDB } from '@/database/core/getTestDB';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -420,7 +423,7 @@ describe('DocumentHistoryService', () => {
|
||||
documentId: doc.id,
|
||||
editorData: { v: i },
|
||||
saveSource: 'autosave',
|
||||
savedAt: new Date(2026, 3, 1, 0, i, 0),
|
||||
savedAt: new Date(2026, 3, 1, 0, i * 10, 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,6 +466,182 @@ describe('DocumentHistoryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave window coalescing', () => {
|
||||
const base = new Date('2026-04-01T10:00:00Z');
|
||||
const minutes = (n: number) => new Date(base.getTime() + n * 60 * 1000);
|
||||
|
||||
const listRows = (documentId: string) =>
|
||||
serverDB
|
||||
.select()
|
||||
.from(documentHistories)
|
||||
.where(eq(documentHistories.documentId, documentId))
|
||||
.orderBy(desc(documentHistories.savedAt), desc(documentHistories.id));
|
||||
|
||||
it('should overwrite the latest autosave row within the window', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(5),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].editorData).toEqual({ v: 2 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(5));
|
||||
});
|
||||
|
||||
it('should insert a new row once the save falls into the next window bucket', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
const windowMinutes = DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS / 60_000;
|
||||
|
||||
for (const [i, at] of [
|
||||
base,
|
||||
minutes(5),
|
||||
minutes(windowMinutes),
|
||||
minutes(windowMinutes + 5),
|
||||
].entries()) {
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: i + 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: at,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 4 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(windowMinutes + 5));
|
||||
expect(rows[1].editorData).toEqual({ v: 2 });
|
||||
expect(rows[1].savedAt).toEqual(minutes(5));
|
||||
});
|
||||
|
||||
it('should start a new window when a non-autosave version is the latest', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'manual',
|
||||
savedAt: minutes(1),
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 3 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(2),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.saveSource)).toEqual(['autosave', 'manual', 'autosave']);
|
||||
});
|
||||
|
||||
it('should never coalesce manual saves', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'manual',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'manual',
|
||||
savedAt: minutes(1),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should insert a new row within the same window when breakAutosaveWindow is true', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
breakAutosaveWindow: true,
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(3),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 2 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(3));
|
||||
expect(rows[1].editorData).toEqual({ v: 1 });
|
||||
expect(rows[1].savedAt).toEqual(base);
|
||||
});
|
||||
|
||||
it('should coalesce into the break row on the next autosave without the flag', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
breakAutosaveWindow: true,
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(3),
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 3 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(5),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 3 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(5));
|
||||
expect(rows[1].editorData).toEqual({ v: 1 });
|
||||
expect(rows[1].savedAt).toEqual(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentHistoryItem', () => {
|
||||
it('should resolve head as current document state', async () => {
|
||||
const editorData = createValidEditorData('Head content');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { documentHistories, documents } from '@lobechat/database/schemas';
|
||||
import { and, desc, eq, gte, inArray, lt, or } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS,
|
||||
DOCUMENT_HISTORY_QUERY_LIST_LIMIT,
|
||||
DOCUMENT_HISTORY_SOURCE_LIMITS,
|
||||
} from '@/const/documentHistory';
|
||||
@@ -46,6 +47,7 @@ export class DocumentHistoryService {
|
||||
buildWorkspaceWhere({ userId: this.userId, workspaceId: this.workspaceId }, documentHistories);
|
||||
|
||||
createHistory = async (params: {
|
||||
breakAutosaveWindow?: boolean;
|
||||
documentId: string;
|
||||
editorData: Record<string, any>;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
@@ -61,6 +63,32 @@ export class DocumentHistoryService {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// Autosave versions coalesce into fixed 10-min windows (Notion-like),
|
||||
// bucketed on the clock grid so the anchor stays immutable even though the
|
||||
// overwritten row's savedAt keeps moving — a sliding anchor would collapse
|
||||
// an entire continuous editing session into a single version.
|
||||
// Any non-autosave version in between closes the window.
|
||||
if (params.saveSource === 'autosave' && !params.breakAutosaveWindow) {
|
||||
const latest = await this.db.query.documentHistories.findFirst({
|
||||
orderBy: [desc(documentHistories.savedAt), desc(documentHistories.id)],
|
||||
where: and(eq(documentHistories.documentId, params.documentId), this.historiesOwnership()),
|
||||
});
|
||||
|
||||
const withinWindow =
|
||||
latest?.saveSource === 'autosave' &&
|
||||
Math.floor(latest.savedAt.getTime() / DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS) ===
|
||||
Math.floor(params.savedAt.getTime() / DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS);
|
||||
|
||||
if (withinWindow) {
|
||||
await this.db
|
||||
.update(documentHistories)
|
||||
.set({ editorData: params.editorData, savedAt: params.savedAt })
|
||||
.where(and(eq(documentHistories.id, latest.id), this.historiesOwnership()));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.insert(documentHistories).values({
|
||||
documentId: params.documentId,
|
||||
editorData: params.editorData,
|
||||
|
||||
@@ -395,6 +395,7 @@ export class DocumentService {
|
||||
if (historyAppended) {
|
||||
savedAt = new Date();
|
||||
await documentHistoryService.createHistory({
|
||||
breakAutosaveWindow: params.breakAutosaveWindow,
|
||||
documentId: id,
|
||||
editorData: currentEditorDataAccepted,
|
||||
saveSource: params.saveSource ?? 'autosave',
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ListDocumentHistoryResult {
|
||||
export type DatabaseLike = LobeChatDatabase | Transaction;
|
||||
|
||||
export interface UpdateDocumentParams {
|
||||
breakAutosaveWindow?: boolean;
|
||||
content?: string;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
|
||||
@@ -4,6 +4,8 @@ export const DOCUMENT_HISTORY_QUERY_LIST_LIMIT = 50;
|
||||
|
||||
export const FREE_DOCUMENT_HISTORY_WINDOW_DAYS = 30;
|
||||
|
||||
export const DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export const DOCUMENT_HISTORY_SOURCE_LIMITS: Record<DocumentHistorySaveSource, number> = {
|
||||
autosave: 20,
|
||||
manual: 20,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DocumentService as DocumentServiceType } from './index';
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
describe('DocumentService.updateDocument', () => {
|
||||
let DocumentService: typeof DocumentServiceType;
|
||||
let service: DocumentServiceType;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@/libs/trpc/client', () => ({
|
||||
lambdaClient: {
|
||||
document: {
|
||||
updateDocument: {
|
||||
mutate: mockMutate,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
({ DocumentService } = await import('./index'));
|
||||
service = new DocumentService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends breakAutosaveWindow: true on first autosave for a doc id', async () => {
|
||||
mockMutate.mockResolvedValue({ historyAppended: true, id: 'doc-1', savedAt: undefined });
|
||||
|
||||
await service.updateDocument({ editorData: 'data', id: 'doc-1', saveSource: 'autosave' });
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ breakAutosaveWindow: true, id: 'doc-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send breakAutosaveWindow on second autosave for same doc id', async () => {
|
||||
mockMutate.mockResolvedValue({ historyAppended: true, id: 'doc-1', savedAt: undefined });
|
||||
|
||||
await service.updateDocument({ editorData: 'data1', id: 'doc-1', saveSource: 'autosave' });
|
||||
await service.updateDocument({ editorData: 'data2', id: 'doc-1', saveSource: 'autosave' });
|
||||
|
||||
expect(mockMutate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.not.objectContaining({ breakAutosaveWindow: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries breakAutosaveWindow on next autosave when first mutation fails', async () => {
|
||||
mockMutate
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValue({ historyAppended: true, id: 'doc-2', savedAt: undefined });
|
||||
|
||||
await expect(
|
||||
service.updateDocument({ editorData: 'data', id: 'doc-2', saveSource: 'autosave' }),
|
||||
).rejects.toThrow('network error');
|
||||
|
||||
await service.updateDocument({ editorData: 'data', id: 'doc-2', saveSource: 'autosave' });
|
||||
|
||||
expect(mockMutate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ breakAutosaveWindow: true, id: 'doc-2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('never sends breakAutosaveWindow for non-autosave saves', async () => {
|
||||
mockMutate.mockResolvedValue({ historyAppended: true, id: 'doc-3', savedAt: undefined });
|
||||
|
||||
await service.updateDocument({ editorData: 'data', id: 'doc-3', saveSource: 'manual' });
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ breakAutosaveWindow: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('non-autosave save does not consume the one-shot for the same doc id', async () => {
|
||||
mockMutate.mockResolvedValue({ historyAppended: true, id: 'doc-4', savedAt: undefined });
|
||||
|
||||
await service.updateDocument({ editorData: 'data', id: 'doc-4', saveSource: 'manual' });
|
||||
await service.updateDocument({ editorData: 'data', id: 'doc-4', saveSource: 'autosave' });
|
||||
|
||||
expect(mockMutate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ breakAutosaveWindow: true, id: 'doc-4' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -123,6 +123,8 @@ export interface DocumentHistoryClientSurface {
|
||||
updateDocument: (params: UpdateDocumentParams) => Promise<UpdateDocumentOutput>;
|
||||
}
|
||||
|
||||
const autosavedOnceIds = new Set<string>();
|
||||
|
||||
export class DocumentService {
|
||||
async createDocument(params: CreateDocumentParams): Promise<DocumentItem> {
|
||||
return lambdaClient.document.createDocument.mutate(params);
|
||||
@@ -212,7 +214,10 @@ export class DocumentService {
|
||||
}
|
||||
|
||||
async updateDocument(params: UpdateDocumentParams): Promise<UpdateDocumentOutput> {
|
||||
const result = await lambdaClient.document.updateDocument.mutate(params);
|
||||
const isFirstAutosave = params.saveSource === 'autosave' && !autosavedOnceIds.has(params.id);
|
||||
const mutationParams = isFirstAutosave ? { ...params, breakAutosaveWindow: true } : params;
|
||||
const result = await lambdaClient.document.updateDocument.mutate(mutationParams);
|
||||
if (isFirstAutosave) autosavedOnceIds.add(params.id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
Reference in New Issue
Block a user