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:
Innei
2026-06-12 20:55:28 +08:00
committed by GitHub
parent 09b5e926bf
commit 34fbd9ffd3
8 changed files with 311 additions and 3 deletions
@@ -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;
+2
View File
@@ -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,
+90
View File
@@ -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' }),
);
});
});
+6 -1
View File
@@ -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,