diff --git a/apps/server/src/routers/lambda/_schema/documentHistory.ts b/apps/server/src/routers/lambda/_schema/documentHistory.ts index 255241bddd..900466d755 100644 --- a/apps/server/src/routers/lambda/_schema/documentHistory.ts +++ b/apps/server/src/routers/lambda/_schema/documentHistory.ts @@ -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; diff --git a/apps/server/src/services/document/__tests__/history.integration.test.ts b/apps/server/src/services/document/__tests__/history.integration.test.ts index d0657efb3b..7ba5230768 100644 --- a/apps/server/src/services/document/__tests__/history.integration.test.ts +++ b/apps/server/src/services/document/__tests__/history.integration.test.ts @@ -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'); diff --git a/apps/server/src/services/document/history.ts b/apps/server/src/services/document/history.ts index b3514d7d71..98189f7d94 100644 --- a/apps/server/src/services/document/history.ts +++ b/apps/server/src/services/document/history.ts @@ -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; 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, diff --git a/apps/server/src/services/document/index.ts b/apps/server/src/services/document/index.ts index d0df89c31f..35d8c9733e 100644 --- a/apps/server/src/services/document/index.ts +++ b/apps/server/src/services/document/index.ts @@ -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', diff --git a/apps/server/src/services/document/types.ts b/apps/server/src/services/document/types.ts index c811e1f4e7..2b43a61d48 100644 --- a/apps/server/src/services/document/types.ts +++ b/apps/server/src/services/document/types.ts @@ -55,6 +55,7 @@ export interface ListDocumentHistoryResult { export type DatabaseLike = LobeChatDatabase | Transaction; export interface UpdateDocumentParams { + breakAutosaveWindow?: boolean; content?: string; editorData?: Record; fileType?: string; diff --git a/src/const/documentHistory.ts b/src/const/documentHistory.ts index bbce4e863d..d1ba624d8a 100644 --- a/src/const/documentHistory.ts +++ b/src/const/documentHistory.ts @@ -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 = { autosave: 20, manual: 20, diff --git a/src/services/document/index.test.ts b/src/services/document/index.test.ts new file mode 100644 index 0000000000..f378a4878e --- /dev/null +++ b/src/services/document/index.test.ts @@ -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' }), + ); + }); +}); diff --git a/src/services/document/index.ts b/src/services/document/index.ts index a1b5e23c2e..4cca39a72a 100644 --- a/src/services/document/index.ts +++ b/src/services/document/index.ts @@ -123,6 +123,8 @@ export interface DocumentHistoryClientSurface { updateDocument: (params: UpdateDocumentParams) => Promise; } +const autosavedOnceIds = new Set(); + export class DocumentService { async createDocument(params: CreateDocumentParams): Promise { return lambdaClient.document.createDocument.mutate(params); @@ -212,7 +214,10 @@ export class DocumentService { } async updateDocument(params: UpdateDocumentParams): Promise { - 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,