From 0b62f700ad7653b8c52e652d2a8c43afb61276af Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Mon, 5 Oct 2026 18:21:40 +0300
Subject: [PATCH 1/2] feat: keep note drafts offline and add version history
---
app/frontend/package.json | 1 +
app/frontend/src/components/NoteEditor.tsx | 15 +++-
app/frontend/src/components/NoteHistory.tsx | 86 +++++++++++++++++++
app/frontend/src/pages/NotePage.tsx | 36 +++++---
app/frontend/test/notes.spec.tsx | 35 ++++++++
app/package-lock.json | 21 +++++
.../migration.sql | 14 +++
app/server/prisma/schema.prisma | 15 ++++
app/server/src/notes/note-versions.service.ts | 70 +++++++++++++++
app/server/src/notes/notes.controller.ts | 27 +++++-
app/server/src/notes/notes.module.ts | 6 +-
app/server/src/realtime/realtime.service.ts | 45 +++++++++-
app/server/test/integration/notes.spec.ts | 56 +++++++++++-
13 files changed, 405 insertions(+), 22 deletions(-)
create mode 100644 app/frontend/src/components/NoteHistory.tsx
create mode 100644 app/server/prisma/migrations/20261005040000_note_versions/migration.sql
create mode 100644 app/server/src/notes/note-versions.service.ts
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 (
+
+ );
+}
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..940b967 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,10 @@ 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('unexpected-response', (_request, response) => {
+ resolve(response.statusCode ?? 0);
+ socket.terminate();
+ });
socket.on('open', () => resolve(101));
});
expect(refused).toBe(403);
@@ -150,4 +154,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()),
+ );
+ });
});
From 55991d2ff1a4db10e46373ac64e0fb0be2ab2cf0 Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Mon, 5 Oct 2026 21:31:25 +0300
Subject: [PATCH 2/2] test: ignore the refused socket's error event
---
app/server/test/integration/notes.spec.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts
index 940b967..d1f4907 100644
--- a/app/server/test/integration/notes.spec.ts
+++ b/app/server/test/integration/notes.spec.ts
@@ -138,6 +138,7 @@ 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('error', () => undefined);
socket.on('unexpected-response', (_request, response) => {
resolve(response.statusCode ?? 0);
socket.terminate();