diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx index cd17c35..d60c537 100644 --- a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx @@ -479,6 +479,8 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { value={content} contentFormat={contentFormat} editable={true} // TODO: pass per-doc permission when RBAC is introduced (e.g. canEdit ?? true) + currentDocId={selectedDoc.id} + onNavigate={handleSelectDoc} onChange={setContent} onContentFormatChange={setContentFormat} labels={{ diff --git a/apps/web/src/app/api/docs/preview/route.test.ts b/apps/web/src/app/api/docs/preview/route.test.ts new file mode 100644 index 0000000..9bccd3d --- /dev/null +++ b/apps/web/src/app/api/docs/preview/route.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractEmbedIds } from './route'; + +// --------------------------------------------------------------------------- +// Pure helper β€” extractEmbedIds +// --------------------------------------------------------------------------- + +describe('extractEmbedIds', () => { + it('returns empty array for null input', () => { + expect(extractEmbedIds(null)).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + expect(extractEmbedIds(undefined)).toEqual([]); + }); + + it('returns empty array when no embed nodes in HTML', () => { + expect(extractEmbedIds('

Hello world

')).toEqual([]); + }); + + it('extracts a single doc ID from a page-embed div', () => { + const html = '
'; + expect(extractEmbedIds(html)).toEqual(['abc-123']); + }); + + it('extracts multiple doc IDs from multiple embed nodes', () => { + const html = [ + '
', + '

Some text

', + '
', + ].join('\n'); + expect(extractEmbedIds(html)).toEqual(['id-1', 'id-2']); + }); + + it('ignores elements without data-doc-id', () => { + const html = '
'; + expect(extractEmbedIds(html)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Route handler β€” GET /api/docs/preview +// --------------------------------------------------------------------------- + +const { createSupabaseServerClient, createSupabaseAdminClient, getAuthContext } = vi.hoisted(() => ({ + createSupabaseServerClient: vi.fn(), + createSupabaseAdminClient: vi.fn(), + getAuthContext: vi.fn(), +})); + +const getDocPreviewMock = vi.fn(); + +vi.mock('@/lib/supabase/server', () => ({ createSupabaseServerClient })); +vi.mock('@/lib/supabase/admin', () => ({ createSupabaseAdminClient })); +vi.mock('@/lib/auth-helpers', () => ({ getAuthContext })); +vi.mock('@/services/docs', () => ({ + DocsService: class { + getDocPreview = getDocPreviewMock; + }, +})); + +// Supabase client stub for collectTransitiveEmbeds BFS (no embeds in target docs) +const fromMock = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ data: [] }), +}); +const mockDbClient = { from: fromMock }; + +import { GET } from './route'; + +const mockAuth = { + id: 'team-member-1', + org_id: 'org-1', + project_id: 'project-1', + project_name: 'Test', + type: 'human' as const, + rateLimitExceeded: false, +}; + +describe('GET /api/docs/preview', () => { + beforeEach(() => { + createSupabaseServerClient.mockReset(); + createSupabaseAdminClient.mockReset(); + getAuthContext.mockReset(); + getDocPreviewMock.mockReset(); + fromMock.mockClear(); + + createSupabaseServerClient.mockResolvedValue(mockDbClient); + createSupabaseAdminClient.mockReturnValue(mockDbClient); + getAuthContext.mockResolvedValue(mockAuth); + }); + + it('returns 401 when not authenticated', async () => { + getAuthContext.mockResolvedValue(null); + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(401); + }); + + it('returns 429 when rate limit exceeded', async () => { + getAuthContext.mockResolvedValue({ + ...mockAuth, + rateLimitExceeded: true, + rateLimitRemaining: 0, + rateLimitResetAt: 9999, + }); + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(429); + }); + + it('returns 400 when q param is missing', async () => { + const res = await GET(new Request('http://localhost/api/docs/preview')); + expect(res.status).toBe(400); + }); + + it('returns 404 when document is not found', async () => { + getDocPreviewMock.mockResolvedValue(null); + const res = await GET(new Request('http://localhost/api/docs/preview?q=nonexistent')); + expect(res.status).toBe(404); + }); + + it('returns preview fields with empty embedChain when doc has no embeds', async () => { + getDocPreviewMock.mockResolvedValue({ + id: 'doc-abc', + title: 'My Doc', + icon: 'πŸ“„', + slug: 'my-doc', + content: '

No embeds here

', + }); + + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toMatchObject({ + id: 'doc-abc', + title: 'My Doc', + icon: 'πŸ“„', + slug: 'my-doc', + embedChain: [], + }); + }); + + it('accepts UUID as q param and passes it to service', async () => { + const uuid = '12345678-1234-1234-1234-123456789abc'; + getDocPreviewMock.mockResolvedValue({ + id: uuid, + title: 'UUID Doc', + icon: null, + slug: 'uuid-doc', + content: null, + }); + + const res = await GET(new Request(`http://localhost/api/docs/preview?q=${uuid}`)); + expect(res.status).toBe(200); + expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', uuid); + }); + + it('accepts slug as q param and passes it to service', async () => { + getDocPreviewMock.mockResolvedValue({ + id: 'doc-xyz', + title: 'Slug Doc', + icon: null, + slug: 'my-slug', + content: null, + }); + + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-slug')); + expect(res.status).toBe(200); + expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', 'my-slug'); + }); +}); diff --git a/apps/web/src/app/api/docs/preview/route.ts b/apps/web/src/app/api/docs/preview/route.ts new file mode 100644 index 0000000..53a5066 --- /dev/null +++ b/apps/web/src/app/api/docs/preview/route.ts @@ -0,0 +1,104 @@ +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { createSupabaseAdminClient } from '@/lib/supabase/admin'; +import { DocsService } from '@/services/docs'; +import { getAuthContext } from '@/lib/auth-helpers'; +import { apiSuccess, ApiErrors } from '@/lib/api-response'; +import { handleApiError } from '@/lib/api-error'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * Extract all doc IDs referenced by page-embed nodes from an HTML string. + * Exported for unit testing. + */ +export function extractEmbedIds(html: string | null | undefined): string[] { + if (!html) return []; + const ids: string[] = []; + const regex = /data-doc-id="([^"]+)"/g; + let m: RegExpExecArray | null; + while ((m = regex.exec(html)) !== null) { + if (m[1]) ids.push(m[1]); + } + return ids; +} + +/** + * BFS over the embed graph starting from `startDocId`, collecting all + * transitively-embedded doc IDs (up to `maxDepth` hops). + * Used to detect indirect circular embeds (Aβ†’Bβ†’A). + */ +async function collectTransitiveEmbeds( + supabase: SupabaseClient, + projectId: string, + startDocId: string, + maxDepth = 5, +): Promise { + const visited = new Set([startDocId]); + const chain: string[] = []; + let frontier = [startDocId]; + + for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) { + const { data } = await supabase + .from('docs') + .select('id, content') + .eq('project_id', projectId) + .in('id', frontier); + + if (!data?.length) break; + + const nextFrontier: string[] = []; + for (const doc of data) { + for (const id of extractEmbedIds(doc.content)) { + if (!visited.has(id)) { + visited.add(id); + chain.push(id); + nextFrontier.push(id); + } + } + } + frontier = nextFrontier; + } + + return chain; +} + +/** + * GET /api/docs/preview?q= + * + * Returns the minimal preview fields needed by page-embed blocks: + * { id, title, icon, slug, embedChain } + * + * `embedChain` lists all doc IDs transitively embedded by the target doc. + * Clients use this to detect indirect circular embeds (Aβ†’Bβ†’A). + * + * Accepts both UUID and slug via the `q` parameter. + */ +export async function GET(request: Request) { + try { + const supabase = await createSupabaseServerClient(); + const me = await getAuthContext(supabase, request); + if (!me) return ApiErrors.unauthorized(); + if (me.rateLimitExceeded) + return ApiErrors.tooManyRequests(me.rateLimitRemaining, me.rateLimitResetAt); + const dbClient = me.type === 'agent' ? createSupabaseAdminClient() : supabase; + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim(); + if (!q) return ApiErrors.badRequest('q is required'); + + const service = new DocsService(dbClient); + const doc = await service.getDocPreview(me.project_id, q); + if (!doc) return ApiErrors.notFound('Document not found'); + + const embedChain = await collectTransitiveEmbeds(dbClient, me.project_id, doc.id); + + return apiSuccess({ + id: doc.id, + title: doc.title, + icon: doc.icon ?? null, + slug: doc.slug, + embedChain, + }); + } catch (err: unknown) { + return handleApiError(err); + } +} diff --git a/apps/web/src/components/docs/doc-editor.tsx b/apps/web/src/components/docs/doc-editor.tsx index d55c2b9..e1054e6 100644 --- a/apps/web/src/components/docs/doc-editor.tsx +++ b/apps/web/src/components/docs/doc-editor.tsx @@ -12,6 +12,7 @@ import TableHeader from '@tiptap/extension-table-header'; import Placeholder from '@tiptap/extension-placeholder'; import { CalloutNode } from './extensions/callout-node'; import { SlashCommandExtension } from './extensions/slash-command'; +import { PageEmbedExtension } from './extensions/page-embed-node'; import { markdownToHtml, htmlToMarkdown } from './lib/content-converter'; type ContentFormat = 'markdown' | 'html'; @@ -20,6 +21,8 @@ export function DocEditor({ value, contentFormat, editable = true, + currentDocId, + onNavigate, onChange, onContentFormatChange, labels, @@ -27,6 +30,10 @@ export function DocEditor({ value: string; contentFormat: ContentFormat; editable?: boolean; + /** ID of the currently open document β€” prevents self-embed in page-embed blocks. */ + currentDocId?: string; + /** Called when user clicks an embedded page link. */ + onNavigate?: (slug: string) => void; onChange: (value: string) => void; onContentFormatChange: (format: ContentFormat) => void; labels: { @@ -60,6 +67,7 @@ export function DocEditor({ Placeholder.configure({ placeholder: labels.placeholder }), CalloutNode, SlashCommandExtension, + PageEmbedExtension.configure({ currentDocId, onNavigate }), ], editable, content: contentFormat === 'markdown' ? markdownToHtml(value) : value, diff --git a/apps/web/src/components/docs/extensions/page-embed-node.test.ts b/apps/web/src/components/docs/extensions/page-embed-node.test.ts new file mode 100644 index 0000000..6f65b92 --- /dev/null +++ b/apps/web/src/components/docs/extensions/page-embed-node.test.ts @@ -0,0 +1,76 @@ +/** + * Unit tests for page-embed-node.tsx + * + * Focuses on pure exported helpers β€” isCircularEmbed. + * The Tiptap extension itself and the React node view require a browser + * environment (jsdom + full editor setup) and are covered by smoke testing. + */ +import { describe, expect, it } from 'vitest'; +import { isCircularEmbed } from './page-embed-node'; + +describe('isCircularEmbed β€” direct self-embed (A embeds A)', () => { + it('returns true when docId matches currentDocId', () => { + expect(isCircularEmbed('doc-abc', 'doc-abc')).toBe(true); + }); + + it('returns false when docId differs from currentDocId', () => { + expect(isCircularEmbed('doc-abc', 'doc-xyz')).toBe(false); + }); + + it('returns false when docId is null (no doc selected yet)', () => { + expect(isCircularEmbed(null, 'doc-abc')).toBe(false); + }); + + it('returns false when docId is undefined', () => { + expect(isCircularEmbed(undefined, 'doc-abc')).toBe(false); + }); + + it('returns false when currentDocId is undefined (editor not bound to a doc)', () => { + expect(isCircularEmbed('doc-abc', undefined)).toBe(false); + }); + + it('returns false when both are undefined', () => { + expect(isCircularEmbed(undefined, undefined)).toBe(false); + }); + + it('returns false when both are null/undefined mix', () => { + expect(isCircularEmbed(null, undefined)).toBe(false); + }); +}); + +describe('isCircularEmbed β€” indirect cycle (Aβ†’Bβ†’A via embedChain)', () => { + it('returns true when currentDocId appears in embedChain (A embeds B, B embeds A)', () => { + // currentDoc = 'doc-a', target = 'doc-b', doc-b embeds doc-a β†’ cycle + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-a', 'doc-c'])).toBe(true); + }); + + it('returns true when currentDocId appears deep in embedChain (Aβ†’Bβ†’Cβ†’A)', () => { + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d', 'doc-a'])).toBe(true); + }); + + it('returns false when embedChain does not contain currentDocId', () => { + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d'])).toBe(false); + }); + + it('returns false when embedChain is empty', () => { + expect(isCircularEmbed('doc-b', 'doc-a', [])).toBe(false); + }); + + it('defaults to empty embedChain when not provided β€” no cycle', () => { + expect(isCircularEmbed('doc-b', 'doc-a')).toBe(false); + }); + + it('returns false when docId is null even if embedChain contains currentDocId', () => { + // No target doc selected β€” cannot form a cycle + expect(isCircularEmbed(null, 'doc-a', ['doc-a'])).toBe(false); + }); + + it('returns false when currentDocId is undefined even if embedChain is non-empty', () => { + expect(isCircularEmbed('doc-b', undefined, ['doc-x', 'doc-y'])).toBe(false); + }); + + it('direct self-embed takes priority regardless of embedChain', () => { + // docId === currentDocId is caught before checking embedChain + expect(isCircularEmbed('doc-a', 'doc-a', [])).toBe(true); + }); +}); diff --git a/apps/web/src/components/docs/extensions/page-embed-node.tsx b/apps/web/src/components/docs/extensions/page-embed-node.tsx new file mode 100644 index 0000000..b46a92a --- /dev/null +++ b/apps/web/src/components/docs/extensions/page-embed-node.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from '@tiptap/react'; +import { useState, useEffect, useCallback } from 'react'; +import { FileText, AlertCircle, RefreshCw } from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Pure helpers β€” exported for unit tests +// --------------------------------------------------------------------------- + +/** + * Returns true if embedding `docId` inside a document identified by + * `currentDocId` would create a circular reference. + * + * Detects two cases: + * - Direct self-embed (A embeds A): docId === currentDocId + * - Indirect cycle (A embeds B, B already embeds A): + * currentDocId appears in `embedChain` (the list of doc IDs transitively + * embedded by the target doc, returned by the preview API). + */ +export function isCircularEmbed( + docId: string | null | undefined, + currentDocId: string | undefined, + embedChain: string[] = [], +): boolean { + if (!docId || !currentDocId) return false; + if (docId === currentDocId) return true; + return embedChain.includes(currentDocId); +} + +// --------------------------------------------------------------------------- +// Extension options +// --------------------------------------------------------------------------- + +export interface PageEmbedOptions { + /** ID of the document currently open in the editor β€” used to prevent self-embed. */ + currentDocId?: string; + /** Called when the user clicks an embedded page link. */ + onNavigate?: (slug: string) => void; +} + +// --------------------------------------------------------------------------- +// Node-view component +// --------------------------------------------------------------------------- + +interface DocPreview { + id: string; + title: string; + icon: string | null; + slug: string; + embedChain: string[]; +} + +type NodeAttrs = { + docId: string | null; + title: string | null; + icon: string | null; + slug: string | null; +}; + +function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps) { + const attrs = node.attrs as NodeAttrs; + const { docId, title, icon, slug } = attrs; + const { currentDocId, onNavigate } = extension.options as PageEmbedOptions; + + const [inputSlug, setInputSlug] = useState(''); + const [doc, setDoc] = useState( + docId + ? { id: docId, title: title ?? '', icon: icon ?? null, slug: slug ?? '', embedChain: [] } + : null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Direct circular embed check (A embeds A) β€” caught from node attrs immediately. + const circular = isCircularEmbed(docId, currentDocId); + + const fetchDoc = useCallback( + async (slugOrId: string) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ q: slugOrId }); + if (currentDocId) params.set('currentDocId', currentDocId); + const res = await fetch(`/api/docs/preview?${params.toString()}`); + if (!res.ok) { + setError(res.status === 404 ? 'Document not found' : 'Document unavailable'); + setLoading(false); + return; + } + const json = (await res.json()) as { data: DocPreview }; + const d = json.data; + + // Indirect circular embed check: target doc's embedChain contains currentDocId (Aβ†’Bβ†’A) + if (isCircularEmbed(d.id, currentDocId, d.embedChain)) { + setError('Circular embed detected β€” this would create an embed cycle.'); + setLoading(false); + return; + } + + setDoc(d); + updateAttributes({ docId: d.id, title: d.title, icon: d.icon ?? null, slug: d.slug }); + } catch { + setError('Failed to load document'); + } finally { + setLoading(false); + } + }, + [updateAttributes, currentDocId], + ); + + // Auto-fetch when docId is present but doc state not yet populated + useEffect(() => { + if (docId && !doc) { + void fetchDoc(docId); + } + }, [docId, doc, fetchDoc]); + + const handleReset = useCallback(() => { + setDoc(null); + setError(null); + setInputSlug(''); + updateAttributes({ docId: null, title: null, icon: null, slug: null }); + }, [updateAttributes]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const val = inputSlug.trim(); + if (val) void fetchDoc(val); + }, + [inputSlug, fetchDoc], + ); + + // --- Circular embed (direct: A embeds A) --- + if (circular) { + return ( + +
+ + Circular embed detected β€” a document cannot embed itself. +
+
+ ); + } + + // --- No doc selected β€” show picker --- + if (!docId) { + return ( + +
+ + setInputSlug(e.target.value)} + placeholder="Enter document slug or ID…" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-[color:var(--operator-muted)]" + autoFocus + /> + + +
+ ); + } + + // --- Loading --- + if (loading) { + return ( + +
+ + Loading document… +
+
+ ); + } + + // --- Error / unavailable / circular (indirect) --- + if (error) { + return ( + +
+ + {error} + +
+
+ ); + } + + // --- Loaded preview --- + if (doc) { + return ( + +
onNavigate?.(doc.slug)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onNavigate?.(doc.slug); + }} + className="group flex cursor-pointer items-center gap-3 rounded-xl border border-white/8 bg-white/4 px-4 py-3 transition-colors hover:border-[color:var(--operator-primary)]/30 hover:bg-[color:var(--operator-primary)]/6" + > + {doc.icon ? ( + {doc.icon} + ) : ( + + )} +
+

+ {doc.title} +

+

/{doc.slug}

+
+ +
+
+ ); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Tiptap extension +// --------------------------------------------------------------------------- + +declare module '@tiptap/core' { + interface Commands { + pageEmbed: { + insertPageEmbed: () => ReturnType; + }; + } +} + +export const PageEmbedExtension = Node.create({ + name: 'pageEmbed', + group: 'block', + atom: true, + draggable: true, + + addOptions() { + return { + currentDocId: undefined, + onNavigate: undefined, + }; + }, + + addAttributes() { + return { + docId: { default: null }, + title: { default: null }, + icon: { default: null }, + slug: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-page-embed]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-page-embed': '', + 'data-doc-id': HTMLAttributes['docId'] ?? '', + 'data-title': HTMLAttributes['title'] ?? '', + 'data-icon': HTMLAttributes['icon'] ?? '', + 'data-slug': HTMLAttributes['slug'] ?? '', + }), + ]; + }, + + addCommands() { + return { + insertPageEmbed: + () => + ({ commands }) => + commands.insertContent({ type: this.name, attrs: {} }), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(PageEmbedView); + }, +}); diff --git a/apps/web/src/components/docs/extensions/slash-command.tsx b/apps/web/src/components/docs/extensions/slash-command.tsx index 29e9328..1d2bc1f 100644 --- a/apps/web/src/components/docs/extensions/slash-command.tsx +++ b/apps/web/src/components/docs/extensions/slash-command.tsx @@ -102,6 +102,12 @@ export const defaultSlashItems: SlashMenuItem[] = [ command: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: 'Page Embed', + icon: 'πŸ“„', + command: (editor, range) => + editor.chain().focus().deleteRange(range).insertPageEmbed().run(), + }, ]; interface SlashMenuRef { diff --git a/apps/web/src/components/docs/lib/content-converter.ts b/apps/web/src/components/docs/lib/content-converter.ts index 9f7b39b..12363f9 100644 --- a/apps/web/src/components/docs/lib/content-converter.ts +++ b/apps/web/src/components/docs/lib/content-converter.ts @@ -6,6 +6,19 @@ const turndown = new TurndownService({ bulletListMarker: '-', }); +// Preserve page-embed atoms β€” must be before the generic block rule +turndown.addRule('pageEmbed', { + filter: (node) => node.nodeName === 'DIV' && node.hasAttribute('data-page-embed'), + replacement: (_content, node) => { + const el = node as HTMLElement; + const docId = el.getAttribute('data-doc-id') ?? ''; + const title = el.getAttribute('data-title') ?? ''; + const icon = el.getAttribute('data-icon') ?? ''; + const slug = el.getAttribute('data-slug') ?? ''; + return `\n
\n`; + }, +}); + // Preserve callout divs turndown.addRule('callout', { filter: (node) => diff --git a/apps/web/src/services/docs.ts b/apps/web/src/services/docs.ts index 9f6e0a0..ff6e023 100644 --- a/apps/web/src/services/docs.ts +++ b/apps/web/src/services/docs.ts @@ -130,6 +130,19 @@ export class DocsService { if (error) throw error; } + /** Fetch preview fields (id, title, icon, slug, content) by UUID or slug */ + async getDocPreview(projectId: string, q: string) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(q); + let builder = this.supabase + .from('docs') + .select('id, title, icon, slug, content') + .eq('project_id', projectId); + builder = isUuid ? builder.eq('id', q) : builder.eq('slug', q); + const { data, error } = await builder.maybeSingle(); + if (error) throw error; + return data as { id: string; title: string; icon: string | null; slug: string; content: string | null } | null; + } + async search(projectId: string, query: string, input?: { limit?: number; cursor?: string | null }) { let builder = this.supabase .from('docs')