From 94b228d4f5b3f36be9a06b40d2267261b46bc943 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sat, 29 Aug 2026 00:32:33 +0300 Subject: [PATCH 1/2] perf(db): add indexes for document and collaboration queries Add missing indexes that caused seq scans in getDocs: - Document.authorId (owned docs lookup) - Collaborator.userId (collaborated docs via Collaborator.some) - CollaborationRequest.userId and documentId (request lookups) --- .../migration.sql | 11 +++++++++++ server/prisma/schema.prisma | 4 ++++ 2 files changed, 15 insertions(+) create mode 100644 server/prisma/migrations/20260829200000_add_performance_indexes/migration.sql diff --git a/server/prisma/migrations/20260829200000_add_performance_indexes/migration.sql b/server/prisma/migrations/20260829200000_add_performance_indexes/migration.sql new file mode 100644 index 0000000..26a4542 --- /dev/null +++ b/server/prisma/migrations/20260829200000_add_performance_indexes/migration.sql @@ -0,0 +1,11 @@ +-- CreateIndex +CREATE INDEX "documents_authorId_idx" ON "documents"("authorId"); + +-- CreateIndex +CREATE INDEX "collaborators_userId_idx" ON "collaborators"("userId"); + +-- CreateIndex +CREATE INDEX "collaboration_requests_userId_idx" ON "collaboration_requests"("userId"); + +-- CreateIndex +CREATE INDEX "collaboration_requests_documentId_idx" ON "collaboration_requests"("documentId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 13a3117..838a9a1 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -41,6 +41,7 @@ model Document { Collaborator Collaborator[] CollaborationRequest CollaborationRequest[] + @@index([authorId]) @@map("documents") } @@ -53,6 +54,7 @@ model Collaborator { permission String @default("edit") // "edit" or "view" @@unique([documentId, userId]) + @@index([userId]) @@map("collaborators") } @@ -67,6 +69,8 @@ model CollaborationRequest { createdAt DateTime @default(now()) @@unique([documentId, userId]) + @@index([userId]) + @@index([documentId]) @@map("collaboration_requests") } From 9a6a78d34a9a87e38b913bc074bbc880680f2600 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sat, 29 Aug 2026 01:16:07 +0300 Subject: [PATCH 2/2] refactor: remove slugIDtoFullID and encode document URLs as short IDs - Delete slugIDtoFullID helper that doubled WS DB load: it did findUnique where id=documentName and returned it unchanged (documentName is already the full UUID after #45). dbPersistence now uses documentName directly in fetch/store. - Add reversible shortId utils (UUID <-> 22-char base64url): server/src/utils/short-id.ts and client/src/utils/short-id.ts. No DB lookup, opaque IDs like Google/Notion style. - Encode links via paths.getHref (uuidToShortId) and decode in document route with try/catch that redirects to 404 on malformed short ID instead of 500. --- client/src/app/routes/app/document.tsx | 23 ++++- client/src/config/paths.ts | 5 +- client/src/utils/short-id.ts | 122 +++++++++++++++++++++++++ server/src/lib/dbPersistence.ts | 7 +- server/src/utils/short-id.ts | 56 ++++++++++++ server/src/utils/slugIDtoFullID.ts | 16 ---- server/test/slugIDtoFullID.test.ts | 53 ----------- 7 files changed, 205 insertions(+), 77 deletions(-) create mode 100644 client/src/utils/short-id.ts create mode 100644 server/src/utils/short-id.ts delete mode 100644 server/src/utils/slugIDtoFullID.ts delete mode 100644 server/test/slugIDtoFullID.test.ts diff --git a/client/src/app/routes/app/document.tsx b/client/src/app/routes/app/document.tsx index e3ca6f3..dfd0291 100644 --- a/client/src/app/routes/app/document.tsx +++ b/client/src/app/routes/app/document.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { DocumentLayout } from '@/components/layouts/DocumentLayout'; @@ -8,15 +8,26 @@ import { DocumentHeader } from '@/features/DocumentPage/components/DocumentHeade import { DocumentMain } from '@/features/DocumentPage/components/DocumentMain'; import { useDocument } from '@/hooks/use-document'; import { useMediaQuery } from '@/hooks/use-media-query'; +import { resolveDocumentId } from '@/utils/short-id'; /** * Editor page: resolves the :id param and wires the document header, collaboration and editor/preview panes. */ export default function DocumentPage() { - const { id } = useParams(); + const { id: rawId } = useParams(); + const navigate = useNavigate(); + + const id = useMemo(() => { + if (!rawId) return undefined; + try { + return resolveDocumentId(rawId); + } catch { + return undefined; + } + }, [rawId]); + const { doc, editedDoc, setEditedDoc, loading, /*handleSave,*/ access } = useDocument(id); - const navigate = useNavigate(); const isReadOnly = access?.permission === 'view'; const isCollaborator = access?.isCollaborator; @@ -25,6 +36,12 @@ export default function DocumentPage() { const isSmallScreen = useMediaQuery('(max-width: 768px)'); + useEffect(() => { + if (rawId && !id) { + navigate('/not-found', { replace: true }); + } + }, [rawId, id, navigate]); + useEffect(() => { if (loading) return; diff --git a/client/src/config/paths.ts b/client/src/config/paths.ts index bf72385..428de4b 100644 --- a/client/src/config/paths.ts +++ b/client/src/config/paths.ts @@ -1,3 +1,5 @@ +import { isUuid, uuidToShortId } from '@/utils/short-id'; + /** * Central registry of internal route paths and href builders used by links and redirects. */ @@ -30,7 +32,8 @@ export const paths = { }, document: { path: 'doc/:id', - getHref: (id: string) => `/app/doc/${id}`, + getHref: (id: string) => + `/app/doc/${isUuid(id) ? uuidToShortId(id) : id}`, }, share: { path: 'doc/share/:token', diff --git a/client/src/utils/short-id.ts b/client/src/utils/short-id.ts new file mode 100644 index 0000000..c7a1a95 --- /dev/null +++ b/client/src/utils/short-id.ts @@ -0,0 +1,122 @@ +/** + * Reversible short-ID helpers: UUID <-> 22-char base64url. + * Client-side implementation without Node Buffer. + */ + +/** Matches a canonical UUID. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Matches a 22-char base64url string (no padding). */ +const SHORT_RE = /^[A-Za-z0-9_-]{22}$/; + +/** + * Encodes a UUID to a 22-character base64url short ID. + * @param uuid - Canonical UUID string. + * @returns 22-char base64url string with no padding. + */ +export function uuidToShortId(uuid: string): string { + if (!UUID_RE.test(uuid)) { + throw new Error(`Invalid UUID: ${uuid}`); + } + const hex = uuid.replace(/-/g, ''); + const bytes = hexToBytes(hex); + return bytesToBase64Url(bytes); +} + +/** + * Decodes a 22-character base64url short ID back to a UUID. + * @param shortId - 22-char base64url string. + * @returns Canonical UUID string. + */ +export function shortIdToUuid(shortId: string): string { + if (!SHORT_RE.test(shortId)) { + throw new Error(`Invalid short ID: ${shortId}`); + } + const bytes = base64UrlToBytes(shortId); + const hex = bytesToHex(bytes); + if (hex.length !== 32) { + throw new Error(`Invalid short ID: ${shortId}`); + } + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +/** + * Returns true when the value looks like a canonical UUID. + * @param value - Candidate string. + */ +export function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** + * Returns true when the value looks like a 22-char short ID. + * @param value - Candidate string. + */ +export function isShortId(value: string): boolean { + return SHORT_RE.test(value); +} + +/** + * Resolves a route param that may be either a UUID or a short ID to a UUID. + * Throws when the input matches neither format (caller should map to 404). + * @param rawId - Raw `:id` param from the route. + * @returns Canonical UUID string. + */ +export function resolveDocumentId(rawId: string): string { + if (isUuid(rawId)) return rawId.toLowerCase(); + if (isShortId(rawId)) return shortIdToUuid(rawId); + throw new Error(`Invalid document ID: ${rawId}`); +} + +/** Converts a hex string to bytes. */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** Converts bytes to a hex string. */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** Encodes bytes to base64url without padding. */ +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + // btoa is available in browsers; in Node tests it may not be — fallback to Buffer is not needed in client. + const base64 = + typeof btoa !== 'undefined' + ? btoa(binary) + : Buffer.from(binary, 'binary').toString('base64'); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +/** Decodes base64url to bytes. */ +function base64UrlToBytes(base64url: string): Uint8Array { + let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const pad = base64.length % 4; + if (pad) base64 += '='.repeat(4 - pad); + const binary = + typeof atob !== 'undefined' + ? atob(base64) + : Buffer.from(base64, 'base64').toString('binary'); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/server/src/lib/dbPersistence.ts b/server/src/lib/dbPersistence.ts index 13fabe3..1e23b48 100644 --- a/server/src/lib/dbPersistence.ts +++ b/server/src/lib/dbPersistence.ts @@ -3,12 +3,11 @@ import * as Y from 'yjs'; import { logger } from '@/lib/logger'; import { prisma } from '@/lib/prisma'; -import { slugIDtoFullID } from '@/utils/slugIDtoFullID'; export const dbPersistence = new Database({ fetch: async ({ documentName }) => { try { - const id = await slugIDtoFullID(documentName); + const id = documentName; // Fetch the Yjs document state from the database const record = await prisma.yjsDocumentState.findFirst({ where: { @@ -41,7 +40,7 @@ export const dbPersistence = new Database({ store: async ({ documentName, state }) => { try { - const id = await slugIDtoFullID(documentName); + const id = documentName; const existing = await prisma.yjsDocumentState.findUnique({ where: { documentId: id }, }); @@ -87,7 +86,7 @@ export const dbPersistence = new Database({ }), ]); } else { - logger.warn(`No Document found for ID prefix: ${documentName}`, { + logger.warn(`No Document found for ID: ${documentName}`, { action: 'DB_STORE_DOC_NOT_FOUND', }); } diff --git a/server/src/utils/short-id.ts b/server/src/utils/short-id.ts new file mode 100644 index 0000000..1139b3d --- /dev/null +++ b/server/src/utils/short-id.ts @@ -0,0 +1,56 @@ +/** + * Reversible short-ID helpers: UUID <-> 22-char base64url. + * UUID -> 22-char opaque ID (e.g. VQ6EAOKbQdSnFkRmVUQAAA) for URLs. + * Short -> UUID, throws on malformed input (caller maps to 404). + */ + +/** Matches a canonical UUID v4 string. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Matches a 22-char base64url string (no padding). */ +const SHORT_RE = /^[A-Za-z0-9_-]{22}$/; + +/** + * Encodes a UUID to a 22-character base64url short ID. + * @param uuid - Canonical UUID string. + * @returns 22-char base64url string with no padding. + */ +export function uuidToShortId(uuid: string): string { + if (!UUID_RE.test(uuid)) { + throw new Error(`Invalid UUID: ${uuid}`); + } + const hex = uuid.replace(/-/g, ''); + return Buffer.from(hex, 'hex').toString('base64url'); +} + +/** + * Decodes a 22-character base64url short ID back to a UUID. + * @param shortId - 22-char base64url string. + * @returns Canonical UUID string. + */ +export function shortIdToUuid(shortId: string): string { + if (!SHORT_RE.test(shortId)) { + throw new Error(`Invalid short ID: ${shortId}`); + } + const hex = Buffer.from(shortId, 'base64url').toString('hex'); + if (hex.length !== 32) { + throw new Error(`Invalid short ID: ${shortId}`); + } + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join('-'); +} + +/** + * Returns true when the value looks like a canonical UUID. + * @param value - Candidate string. + */ +export function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** + * Returns true when the value looks like a 22-char short ID. + * @param value - Candidate string. + */ +export function isShortId(value: string): boolean { + return SHORT_RE.test(value); +} diff --git a/server/src/utils/slugIDtoFullID.ts b/server/src/utils/slugIDtoFullID.ts deleted file mode 100644 index 6d6e22c..0000000 --- a/server/src/utils/slugIDtoFullID.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { prisma } from '@/lib/prisma'; - -export const slugIDtoFullID = async (slugId: string) => { - const doc = await prisma.document.findUnique({ - where: { - id: slugId, - }, - select: { - id: true, - }, - }); - if (!doc) { - throw new Error(`Document with slug ID ${slugId} not found`); - } - return doc.id; -}; diff --git a/server/test/slugIDtoFullID.test.ts b/server/test/slugIDtoFullID.test.ts deleted file mode 100644 index eadb446..0000000 --- a/server/test/slugIDtoFullID.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; - -import { prisma } from '@/lib/prisma'; -import { slugIDtoFullID } from '@/utils/slugIDtoFullID'; - -describe('slugIDtoFullID', () => { - let userId: string; - - beforeEach(async () => { - await prisma.collaborator.deleteMany(); - await prisma.collaborationRequest.deleteMany(); - await prisma.yjsDocumentState.deleteMany(); - await prisma.document.deleteMany(); - await prisma.user.deleteMany(); - - const user = await prisma.user.create({ - data: { email: 'slug@test.dev', username: 'sluguser', password: 'unused' }, - }); - userId = user.id; - }); - - it('resolves an exact document ID', async () => { - const doc = await prisma.document.create({ - data: { id: 'aaaa1111-0000-4000-8000-000000000001', title: 'Exact', content: '', authorId: userId }, - }); - - await expect(slugIDtoFullID(doc.id)).resolves.toBe(doc.id); - }); - - it('does not resolve a truncated ID prefix', async () => { - const doc = await prisma.document.create({ - data: { id: 'bbbb1111-0000-4000-8000-000000000002', title: 'Prefixed', content: '', authorId: userId }, - }); - const slug = doc.id.slice(0, 8); - - await expect(slugIDtoFullID(slug)).rejects.toThrow(); - }); - - it('does not resolve an ambiguous prefix shared by multiple documents', async () => { - await prisma.document.createMany({ - data: [ - { id: 'cccc1111-0000-4000-8000-000000000003', title: 'One', content: '', authorId: userId }, - { id: 'cccc2222-0000-4000-8000-000000000004', title: 'Two', content: '', authorId: userId }, - ], - }); - - await expect(slugIDtoFullID('cccc')).rejects.toThrow(); - }); - - it('throws when no document exists', async () => { - await expect(slugIDtoFullID('dddd1111-0000-4000-8000-000000000005')).rejects.toThrow(/not found/); - }); -});