diff --git a/app/frontend/package.json b/app/frontend/package.json index bfac044..21e8d5b 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -36,6 +36,7 @@ "@tiptap/react": "3.31.4", "@tiptap/starter-kit": "3.31.4", "uqr": "0.1.3", + "y-indexeddb": "9.0.12", "yjs": "13.6.33" } } diff --git a/app/frontend/src/components/NoteEditor.tsx b/app/frontend/src/components/NoteEditor.tsx index 2ca06aa..331c886 100644 --- a/app/frontend/src/components/NoteEditor.tsx +++ b/app/frontend/src/components/NoteEditor.tsx @@ -7,6 +7,7 @@ import { TaskItem, TaskList } from '@tiptap/extension-list'; import { Placeholder } from '@tiptap/extensions'; import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; +import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; import { useAuth } from '../auth'; import { realtimeUrl } from '../notes'; @@ -118,7 +119,17 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection; if (!editor) return ; const label = - status === 'disconnected' ? 'Offline. Reconnecting…' : status === 'connecting' ? 'Connecting…' : unsynced > 0 ? 'Saving…' : editable ? 'Saved' : 'View only'; + status === 'disconnected' + ? editable + ? 'Offline. Changes are kept on this device' + : 'Offline' + : status === 'connecting' + ? 'Connecting…' + : unsynced > 0 + ? 'Saving…' + : editable + ? 'Saved' + : 'View only'; return (
@@ -150,6 +161,7 @@ export default function NoteEditor({ editable, noteId }: { editable: boolean; no useEffect(() => { const document = new Y.Doc(); + const drafts = new IndexeddbPersistence(`strata-note-${noteId}`, document); const websocketProvider = new HocuspocusProviderWebsocket({ url: realtimeUrl() }); const provider = new HocuspocusProvider({ name: noteId, document, websocketProvider, token: () => accessToken.current() }); provider.attach(); @@ -157,6 +169,7 @@ export default function NoteEditor({ editable, noteId }: { editable: boolean; no return () => { provider.destroy(); websocketProvider.destroy(); + void drafts.destroy(); document.destroy(); }; }, [noteId]); diff --git a/app/frontend/src/components/NoteHistory.tsx b/app/frontend/src/components/NoteHistory.tsx new file mode 100644 index 0000000..18d09c5 --- /dev/null +++ b/app/frontend/src/components/NoteHistory.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { Dialog, EmptyState, ListSkeleton, timeAgo } from '@project-graphite/ui'; +import { useAuth } from '../auth'; +import { useAction } from '../useAction'; +import { useResource } from '../useResource'; +import { LoadError } from './LoadError'; + +interface Version { + id: string; + createdAt: string; + createdBy: string | null; +} + +export function NoteHistory({ editable, noteId, onClose }: { editable: boolean; noteId: string; onClose: () => void }) { + const auth = useAuth(); + const versions = useResource(`/notes/${noteId}/versions`, true); + const [chosen, setChosen] = useState(); + const preview = useResource<{ id: string; text: string }>(chosen ? `/notes/${noteId}/versions/${chosen}` : null, true); + const restoring = useAction(); + + return ( + +
+ {versions.error ? ( + + ) : !versions.data ? ( + + ) : versions.data.length === 0 ? ( + + ) : ( + <> +
    + {versions.data.map((version) => ( +
  • + +
  • + ))} +
+ {chosen && + (preview.error ? ( + + ) : !preview.data || preview.data.id !== chosen ? ( + + ) : ( +
+ {preview.data.text || 'This version is empty.'} +
+ ))} +
+ + {editable && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/app/frontend/src/pages/NotePage.tsx b/app/frontend/src/pages/NotePage.tsx index 815d2a5..36cdd6a 100644 --- a/app/frontend/src/pages/NotePage.tsx +++ b/app/frontend/src/pages/NotePage.tsx @@ -1,8 +1,9 @@ -import { Fragment, lazy, Suspense } from 'react'; +import { Fragment, lazy, Suspense, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; import { EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { LoadError } from '../components/LoadError'; +import { NoteHistory } from '../components/NoteHistory'; import { noteTitle, type Note, type NoteDetails } from '../notes'; import { useSpaces } from '../spaces'; import { useAction } from '../useAction'; @@ -19,6 +20,7 @@ export function NotePage() { const pages = useResource(note.data ? `/spaces/${note.data.spaceId}/notes` : null, true); const renaming = useAction(); const adding = useAction(); + const [history, setHistory] = useState(false); if (note.status === 404) { return ( @@ -45,19 +47,24 @@ export function NotePage() { return (
- +
+ + +
)} + {history && setHistory(false)} />}
); } diff --git a/app/frontend/test/notes.spec.tsx b/app/frontend/test/notes.spec.tsx index a868bb0..fc565f9 100644 --- a/app/frontend/test/notes.spec.tsx +++ b/app/frontend/test/notes.spec.tsx @@ -145,4 +145,39 @@ describe('Notes', () => { await render('/notes/gone'); expect(container.textContent).toContain('Page not found'); }); + + it('previews an earlier version and restores it, but only for editors', async () => { + const versions = [ + { id: 'v2', createdAt: '2026-10-05T11:00:00Z', createdBy: 'Sam' }, + { id: 'v1', createdAt: '2026-10-05T09:00:00Z', createdBy: 'Amr' }, + ]; + let editable = true; + const fetchMock = serve((path, init) => { + if (path === '/notes/trips') return json({ ...note('trips', 'Trips'), editable, path: [] }); + if (path === '/notes/trips/versions') return json(versions); + if (path === '/notes/trips/versions/v1') return json({ id: 'v1', createdAt: versions[1]!.createdAt, text: 'Lisbon\nPorto' }); + if (path === '/notes/trips/versions/v1/restore' && init?.method === 'POST') return new Response(null, { status: 204 }); + return undefined; + }); + await render('/notes/trips'); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'History')!.click()); + const dialog = container.querySelector('dialog')!; + expect(dialog.textContent).toContain('Sam'); + const restore = [...dialog.querySelectorAll('button')].find((button) => button.textContent === 'Restore this version')!; + expect(restore.disabled).toBe(true); + await act(async () => [...dialog.querySelectorAll('button')].find((button) => button.textContent?.includes('Amr'))!.click()); + expect(dialog.textContent).toContain('Lisbon\nPorto'); + await act(async () => restore.click()); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/notes/trips/versions/v1/restore', expect.objectContaining({ method: 'POST' })); + expect(container.querySelector('dialog')).toBeNull(); + expect(container.querySelector('.snackbar')?.textContent).toContain('Restored.'); + + editable = false; + act(() => root.unmount()); + root = createRoot(container); + await render('/notes/trips'); + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'History')!.click()); + expect([...container.querySelectorAll('dialog button')].some((button) => button.textContent === 'Restore this version')).toBe(false); + }); }); diff --git a/app/package-lock.json b/app/package-lock.json index 21bb1f6..529aaec 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -30,6 +30,7 @@ "@tiptap/react": "3.31.4", "@tiptap/starter-kit": "3.31.4", "uqr": "0.1.3", + "y-indexeddb": "9.0.12", "yjs": "13.6.33" }, "devDependencies": { @@ -7998,6 +7999,26 @@ "node": ">=0.4" } }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y-protocols": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", diff --git a/app/server/prisma/migrations/20261005040000_note_versions/migration.sql b/app/server/prisma/migrations/20261005040000_note_versions/migration.sql new file mode 100644 index 0000000..f92ae13 --- /dev/null +++ b/app/server/prisma/migrations/20261005040000_note_versions/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE "note_versions" ( + "id" UUID NOT NULL, + "item_id" UUID NOT NULL, + "state" BYTEA NOT NULL, + "created_by" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "note_versions_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "note_versions_item_id_created_at_idx" ON "note_versions"("item_id", "created_at"); + +ALTER TABLE "note_versions" ADD CONSTRAINT "note_versions_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "note_versions" ADD CONSTRAINT "note_versions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma index 84b51f8..572fe2e 100644 --- a/app/server/prisma/schema.prisma +++ b/app/server/prisma/schema.prisma @@ -104,6 +104,7 @@ model User { dashboards Dashboard[] tidyBatches TidyBatch[] tidyRules TidyRule[] + noteVersions NoteVersion[] @@map("users") } @@ -250,6 +251,7 @@ model Item { note Note? @relation("NoteItem") childNotes Note[] @relation("NoteParent") noteDocument NoteDocument? + noteVersions NoteVersion[] tidyChanges TidyChange[] @@index([spaceId, updatedAt]) @@ -669,3 +671,16 @@ model NoteDocument { @@map("note_documents") } + +model NoteVersion { + id String @id @default(uuid()) @db.Uuid + itemId String @map("item_id") @db.Uuid + state Bytes + createdById String? @map("created_by") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([itemId, createdAt]) + @@map("note_versions") +} diff --git a/app/server/src/notes/note-versions.service.ts b/app/server/src/notes/note-versions.service.ts new file mode 100644 index 0000000..e66e847 --- /dev/null +++ b/app/server/src/notes/note-versions.service.ts @@ -0,0 +1,70 @@ +import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; +import * as Y from 'yjs'; +import { MaintenanceScheduler } from '../jobs/maintenance.scheduler'; +import { PrismaService } from '../prisma/prisma.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { documentText } from './document-text'; +import { NotesService } from './notes.service'; + +@Injectable() +export class NoteVersionsService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly notes: NotesService, + private readonly realtime: RealtimeService, + private readonly maintenance: MaintenanceScheduler, + ) {} + + onModuleInit() { + this.maintenance.register((now) => this.thin(now)); + } + + async list(userId: string, noteId: string) { + await this.notes.note(userId, noteId, 'read'); + const versions = await this.prisma.noteVersion.findMany({ + where: { itemId: noteId }, + orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], + take: 100, + select: { id: true, createdAt: true, createdBy: { select: { displayName: true } } }, + }); + return versions.map((version) => ({ id: version.id, createdAt: version.createdAt, createdBy: version.createdBy?.displayName ?? null })); + } + + async preview(userId: string, noteId: string, versionId: string) { + await this.notes.note(userId, noteId, 'read'); + const version = await this.version(noteId, versionId); + const document = new Y.Doc(); + Y.applyUpdate(document, new Uint8Array(version.state)); + return { id: version.id, createdAt: version.createdAt, text: documentText(document) }; + } + + async restore(userId: string, noteId: string, versionId: string) { + const found = await this.notes.note(userId, noteId, 'edit'); + const version = await this.version(noteId, versionId); + await this.realtime.restore(noteId, new Uint8Array(version.state), { userId, role: found.role }); + } + + thin(now: Date) { + return this.prisma.$executeRaw` + DELETE FROM "note_versions" AS "version" + USING ( + SELECT "id", row_number() OVER ( + PARTITION BY "item_id", + date_trunc(CASE WHEN "created_at" < ${now}::timestamp - interval '90 days' THEN 'week' ELSE 'day' END, "created_at") + ORDER BY "created_at" DESC + ) AS "rank" + FROM "note_versions" + WHERE "created_at" < ${now}::timestamp - interval '7 days' + ) AS "ranked" + WHERE "version"."id" = "ranked"."id" AND "ranked"."rank" > 1`; + } + + private async version(noteId: string, versionId: string) { + const version = await this.prisma.noteVersion.findFirst({ + where: { id: versionId, itemId: noteId }, + select: { id: true, createdAt: true, state: true }, + }); + if (!version) throw new NotFoundException('That version is not available'); + return version; + } +} diff --git a/app/server/src/notes/notes.controller.ts b/app/server/src/notes/notes.controller.ts index 72fb0f3..7904454 100644 --- a/app/server/src/notes/notes.controller.ts +++ b/app/server/src/notes/notes.controller.ts @@ -1,16 +1,20 @@ -import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { Scope } from '../access-tokens/scopes'; import { AuthenticatedUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { UuidPipe } from '../validation/uuid.pipe'; import { CreateNoteDto, UpdateNoteDto } from './dto/notes.dto'; +import { NoteVersionsService } from './note-versions.service'; import { NotesService } from './notes.service'; @Controller() @UseGuards(JwtAuthGuard) export class NotesController { - constructor(private readonly notes: NotesService) {} + constructor( + private readonly notes: NotesService, + private readonly versions: NoteVersionsService, + ) {} @Get('spaces/:spaceId/notes') @Scope('items:read') @@ -30,6 +34,25 @@ export class NotesController { return this.notes.get(user.id, id); } + @Get('notes/:id/versions') + @Scope('items:read') + listVersions(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + return this.versions.list(user.id, id); + } + + @Get('notes/:id/versions/:versionId') + @Scope('items:read') + version(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('versionId', UuidPipe) versionId: string) { + return this.versions.preview(user.id, id, versionId); + } + + @Post('notes/:id/versions/:versionId/restore') + @Scope('items:write') + @HttpCode(204) + async restore(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Param('versionId', UuidPipe) versionId: string) { + await this.versions.restore(user.id, id, versionId); + } + @Patch('notes/:id') @Scope('items:write') update(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: UpdateNoteDto) { diff --git a/app/server/src/notes/notes.module.ts b/app/server/src/notes/notes.module.ts index c9f90ae..b8dc320 100644 --- a/app/server/src/notes/notes.module.ts +++ b/app/server/src/notes/notes.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { RealtimeModule } from '../realtime/realtime.module'; +import { NoteVersionsService } from './note-versions.service'; import { NotesController } from './notes.controller'; import { NotesService } from './notes.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, RealtimeModule], controllers: [NotesController], - providers: [NotesService], + providers: [NotesService, NoteVersionsService], exports: [NotesService], }) export class NotesModule {} diff --git a/app/server/src/realtime/realtime.service.ts b/app/server/src/realtime/realtime.service.ts index 0aefce9..b9eeb54 100644 --- a/app/server/src/realtime/realtime.service.ts +++ b/app/server/src/realtime/realtime.service.ts @@ -18,6 +18,7 @@ const maxUpdateBytes = 2 * 1024 * 1024; const maxDocumentBytes = 5 * 1024 * 1024; const maxDocumentsPerSocket = 20; const recheckMs = 60_000; +const versionEveryMs = 15 * 60 * 1000; export interface RealtimeContext { userId: string; @@ -68,7 +69,7 @@ export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy if (stored) Y.applyUpdate(document, new Uint8Array(stored.state)); return document; }, - onStoreDocument: async ({ document, documentName, lastContext }) => { + onStoreDocument: async ({ clientsCount, document, documentName, lastContext }) => { const state = Y.encodeStateAsUpdate(document); if (state.byteLength > maxDocumentBytes) { this.logger.warn(`Note ${documentName} is over the size limit and was not saved`); @@ -87,8 +88,10 @@ export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy data: { updatedById: lastContext?.userId ?? undefined, updatedAt: new Date() }, }), ]); + await this.snapshot(documentName, state, lastContext?.userId ?? null, clientsCount === 0); }, - onDisconnect: async ({ documentName, socketId }) => { + onDisconnect: async ({ clientsCount, context, document, documentName, socketId }) => { + if (clientsCount === 0 && context) await this.snapshot(documentName, Y.encodeStateAsUpdate(document), context.userId, true); const open = this.documentsPerSocket.get(socketId); open?.delete(documentName); if (open?.size === 0) this.documentsPerSocket.delete(socketId); @@ -107,9 +110,47 @@ export class RealtimeService implements OnApplicationBootstrap, OnModuleDestroy clearInterval(this.recheck); this.hocuspocus.closeConnections(); this.hocuspocus.flushPendingStores(); + for (const client of this.sockets.clients) client.terminate(); this.sockets.close(); } + async snapshot(itemId: string, state: Uint8Array, userId: string | null, sessionEnded: boolean) { + if (state.byteLength > maxDocumentBytes) return; + const latest = await this.prisma.noteVersion.findFirst({ + where: { itemId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true, state: true }, + }); + if (latest && Buffer.compare(Buffer.from(latest.state), Buffer.from(state)) === 0) return; + if (latest && !sessionEnded && Date.now() - latest.createdAt.getTime() < versionEveryMs) return; + const empty = Y.encodeStateAsUpdate(new Y.Doc()); + if (!latest && Buffer.compare(Buffer.from(empty), Buffer.from(state)) === 0) return; + await this.prisma.noteVersion.create({ data: { itemId, state: Buffer.from(state), createdById: userId } }); + } + + async restore(itemId: string, versionState: Uint8Array, context: RealtimeContext) { + const direct = await this.hocuspocus.openDirectConnection(itemId, context); + try { + await this.snapshot(itemId, Y.encodeStateAsUpdate(direct.document!), context.userId, true); + await direct.transact((document) => { + const snapshot = new Y.Doc(); + Y.applyUpdate(snapshot, versionState); + const target = document.getXmlFragment('default'); + target.delete(0, target.length); + target.insert( + 0, + snapshot + .getXmlFragment('default') + .toArray() + .filter((node): node is Y.XmlElement | Y.XmlText => !(node instanceof Y.XmlHook)) + .map((node) => node.clone()), + ); + }); + } finally { + await direct.disconnect(); + } + } + async recheckAccess() { for (const [name, document] of this.hocuspocus.documents) { for (const connection of document.connections.keys()) { diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts index 3673c0e..d1f4907 100644 --- a/app/server/test/integration/notes.spec.ts +++ b/app/server/test/integration/notes.spec.ts @@ -3,6 +3,7 @@ import { SpaceRole } from '@prisma/client'; import { afterEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; import * as Y from 'yjs'; +import { NoteVersionsService } from '../../src/notes/note-versions.service'; import { RealtimeService } from '../../src/realtime/realtime.service'; import { integrationApp, type Member } from './harness'; @@ -137,7 +138,11 @@ describe('Notes and real-time editing against Postgres', () => { const refused = await new Promise((resolve) => { const socket = new WebSocket(`${strata.base.replace(/^http/, 'ws')}/api/v1/realtime`, { origin: 'https://evil.example' }); - socket.on('unexpected-response', (_request, response) => resolve(response.statusCode ?? 0)); + socket.on('error', () => undefined); + socket.on('unexpected-response', (_request, response) => { + resolve(response.statusCode ?? 0); + socket.terminate(); + }); socket.on('open', () => resolve(101)); }); expect(refused).toBe(403); @@ -150,4 +155,54 @@ describe('Notes and real-time editing against Postgres', () => { await realtime.recheckAccess(); expect(await until(async () => connections(), (count) => count === 0)).toBe(0); }); + + it('keeps versions when editing sessions end, previews and restores them, and thins old ones', async () => { + const owner = await member('historian'); + const viewer = await member('reviewer'); + const space = (await owner.call('POST', '/spaces', { name: 'Drafts' })).body; + await strata.join(space.id, viewer, SpaceRole.VIEWER); + const note = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Speech' })).body; + const realtime = strata.service(RealtimeService); + + const first = await open(owner, note.id); + write(first.document, 'First draft'); + await settle(300); + first.provider.destroy(); + const listed = await until( + async () => (await owner.call('GET', `/notes/${note.id}/versions`)).body as { id: string; createdBy: string }[], + (versions) => versions.length === 1, + ); + expect(listed).toEqual([expect.objectContaining({ createdBy: 'historian' })]); + expect((await owner.call('GET', `/notes/${note.id}/versions/${listed[0]!.id}`)).body.text).toBe('First draft'); + + const second = await open(owner, note.id); + write(second.document, 'Second paragraph'); + await until(async () => textOf(realtime.hocuspocus.documents.get(note.id)!), (text) => text.includes('Second paragraph')); + realtime.hocuspocus.flushPendingStores(); + await settle(300); + expect((await owner.call('GET', `/notes/${note.id}/versions`)).body).toHaveLength(1); + + expect((await viewer.call('GET', `/notes/${note.id}/versions`)).status).toBe(200); + expect((await viewer.call('POST', `/notes/${note.id}/versions/${listed[0]!.id}/restore`)).status).toBe(403); + expect((await owner.call('POST', `/notes/${note.id}/versions/${listed[0]!.id}/restore`)).status).toBe(204); + await until(async () => textOf(second.document), (text) => !text.includes('Second paragraph')); + expect(textOf(second.document)).toContain('First draft'); + expect(textOf(second.document)).not.toContain('Second paragraph'); + const afterRestore = (await owner.call('GET', `/notes/${note.id}/versions`)).body as { id: string }[]; + expect(afterRestore).toHaveLength(2); + expect((await owner.call('GET', `/notes/${note.id}/versions/${afterRestore[0]!.id}`)).body.text).toBe('First draft\nSecond paragraph'); + + const now = new Date('2026-10-05T12:00:00Z'); + const at = (daysAgo: number, hour: number) => new Date(Date.UTC(2026, 9, 5 - daysAgo, hour)); + const state = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())); + await strata.prisma.noteVersion.deleteMany({ where: { itemId: note.id } }); + await strata.prisma.noteVersion.createMany({ + data: [at(1, 9), at(1, 10), at(10, 9), at(10, 15), at(120, 9), at(118, 9), at(115, 9)].map((createdAt) => ({ itemId: note.id, state, createdAt })), + }); + await strata.service(NoteVersionsService).thin(now); + const kept = await strata.prisma.noteVersion.findMany({ where: { itemId: note.id }, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }); + expect(kept.map(({ createdAt }) => createdAt.toISOString())).toEqual( + [at(120, 9), at(115, 9), at(10, 15), at(1, 9), at(1, 10)].map((date) => date.toISOString()), + ); + }); });