Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
15 changes: 14 additions & 1 deletion app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,7 +119,17 @@ function CollaborativeEditor({ connection, editable }: { connection: Connection;

if (!editor) return <LinesSkeleton label="Loading the editor" lines={6} />;
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 (
<div className="note-editor grid gap-3">
Expand Down Expand Up @@ -150,13 +161,15 @@ 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();
setConnection({ document, provider });
return () => {
provider.destroy();
websocketProvider.destroy();
void drafts.destroy();
document.destroy();
};
}, [noteId]);
Expand Down
86 changes: 86 additions & 0 deletions app/frontend/src/components/NoteHistory.tsx
Original file line number Diff line number Diff line change
@@ -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<Version[]>(`/notes/${noteId}/versions`, true);
const [chosen, setChosen] = useState<string>();
const preview = useResource<{ id: string; text: string }>(chosen ? `/notes/${noteId}/versions/${chosen}` : null, true);
const restoring = useAction();

return (
<Dialog onClose={onClose} title="Page history">
<div className="mt-5 grid gap-4">
{versions.error ? (
<LoadError error={versions.error} onRetry={versions.reload} />
) : !versions.data ? (
<ListSkeleton label="Loading the history" rows={3} />
) : versions.data.length === 0 ? (
<EmptyState title="No earlier versions yet" />
) : (
<>
<ul className="panel-rows m-0 grid max-h-56 list-none overflow-y-auto p-0">
{versions.data.map((version) => (
<li key={version.id}>
<button
aria-pressed={chosen === version.id}
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm ${chosen === version.id ? 'bg-line-soft text-ink' : 'text-muted hover:text-ink'}`}
onClick={() => setChosen(version.id)}
type="button"
>
<span>{new Date(version.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span>
<span className="mono-sm text-faint">
{version.createdBy ?? 'someone'} · {timeAgo(version.createdAt)}
</span>
</button>
</li>
))}
</ul>
{chosen &&
(preview.error ? (
<LoadError compact error={preview.error} onRetry={preview.reload} />
) : !preview.data || preview.data.id !== chosen ? (
<ListSkeleton label="Loading this version" rows={2} />
) : (
<div className="max-h-56 overflow-y-auto rounded-lg border border-line p-4 text-sm whitespace-pre-wrap text-ink">
{preview.data.text || 'This version is empty.'}
</div>
))}
<div className="flex justify-end gap-3">
<button className="secondary-button" onClick={onClose} type="button">
Close
</button>
{editable && (
<button
className="primary-button"
disabled={!chosen || restoring.busy}
onClick={() =>
void restoring
.run(async () => {
await auth.request(`/notes/${noteId}/versions/${chosen}/restore`, { method: 'POST' });
return 'Restored. The page as it was just before is kept in the history.';
}, 'Could not restore that version')
.then((done) => done && onClose())
}
type="button"
>
{restoring.busy ? 'Restoring…' : 'Restore this version'}
</button>
)}
</div>
</>
)}
</div>
</Dialog>
);
}
36 changes: 22 additions & 14 deletions app/frontend/src/pages/NotePage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,7 @@ export function NotePage() {
const pages = useResource<Note[]>(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 (
Expand All @@ -45,19 +47,24 @@ export function NotePage() {

return (
<article className="page-enter grid max-w-3xl gap-6">
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1.5 text-sm text-muted">
<Link className="text-muted no-underline hover:text-ink" to={`/spaces/${details.spaceId}/notes`}>
{space?.name ?? 'Space'}
</Link>
{details.path.map((step) => (
<Fragment key={step.id}>
<span aria-hidden="true">/</span>
<Link className="text-muted no-underline hover:text-ink" to={`/notes/${step.id}`}>
{noteTitle(step)}
</Link>
</Fragment>
))}
</nav>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1.5 text-sm text-muted">
<Link className="text-muted no-underline hover:text-ink" to={`/spaces/${details.spaceId}/notes`}>
{space?.name ?? 'Space'}
</Link>
{details.path.map((step) => (
<Fragment key={step.id}>
<span aria-hidden="true">/</span>
<Link className="text-muted no-underline hover:text-ink" to={`/notes/${step.id}`}>
{noteTitle(step)}
</Link>
</Fragment>
))}
</nav>
<button className="text-button text-sm" onClick={() => setHistory(true)} type="button">
History
</button>
</div>
<input
aria-label="Page title"
className="note-title"
Expand Down Expand Up @@ -114,6 +121,7 @@ export function NotePage() {
)}
</section>
)}
{history && <NoteHistory editable={details.editable} noteId={details.id} onClose={() => setHistory(false)} />}
</article>
);
}
35 changes: 35 additions & 0 deletions app/frontend/test/notes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
21 changes: 21 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions app/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ model User {
dashboards Dashboard[]
tidyBatches TidyBatch[]
tidyRules TidyRule[]
noteVersions NoteVersion[]

@@map("users")
}
Expand Down Expand Up @@ -250,6 +251,7 @@ model Item {
note Note? @relation("NoteItem")
childNotes Note[] @relation("NoteParent")
noteDocument NoteDocument?
noteVersions NoteVersion[]
tidyChanges TidyChange[]

@@index([spaceId, updatedAt])
Expand Down Expand Up @@ -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")
}
70 changes: 70 additions & 0 deletions app/server/src/notes/note-versions.service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading