From 607ad06bf261910398faefb18252ead09bd96fdf Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sat, 12 Sep 2026 01:01:23 +0300 Subject: [PATCH 1/4] Add coverage for the exact-block navigate/reveal/highlight contract (issue #83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #83 asks for backlink navigation to a document's exact referring block, with reveal-after-navigation, an accessible highlight, safe degradation for a stale source block, and test coverage for all of it. The general page_link/wiki-link Backlinks panel the issue describes was removed from +page.svelte as part of #120's per-Document sharding work (listIncomingLinks can't cheaply scan across shards) and is blocked on #81/#70, both explicitly pulled out of this milestone — see the clarifying comment posted on the issue before this commit. That exact mechanism already exists and is live today on a structurally identical surface built on the same Backlink/listIncomingLinks primitive: SyncedBlockUsage.svelte's "Used in N places" panel (#block- hrefs, same-document in-place jump vs. cross-document real navigation, scroll+focus+temporary outline highlight via navigateToBlock, safe no-op for a missing block id). This adds the missing coverage issue #83's own checklist calls for, on that surface: - SyncedBlockUsage.svelte had no test file at all — added one covering the #block- href, the same-document/cross-document jump branch (verifying real navigation is left alone across documents, which is what keeps browser Back and keyboard activation working), the empty state, Escape/roving-focus keyboard mechanics, and Detach. - page.svelte.test.ts's existing deep-link suite covered focus but not the highlight styling itself or a stale/deleted block id — added both: the outline/outline-2/outline-accent classes apply and clear (not color alone, and temporary), and a #block- naming a deleted block opens the Document without calling scrollIntoView or applying any highlight. Verified: npm run test (1250, was 1240), npm run test:coverage, npm run check, npm run lint — all pass. Contributes to #83 (not closing it — the panel-reintroduction part of that issue's original scope is still blocked on #81/#70, per the posted clarifying comment). Co-Authored-By: Claude Sonnet 5 --- .../SyncedBlockUsage.svelte.test.ts | 144 ++++++++++++++++++ .../[spaceId]/doc/[id]/page.svelte.test.ts | 56 +++++++ 2 files changed, 200 insertions(+) create mode 100644 src/lib/components/SyncedBlockUsage.svelte.test.ts diff --git a/src/lib/components/SyncedBlockUsage.svelte.test.ts b/src/lib/components/SyncedBlockUsage.svelte.test.ts new file mode 100644 index 0000000..c04fa39 --- /dev/null +++ b/src/lib/components/SyncedBlockUsage.svelte.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'svelte'; +import type { Backlink } from '$lib/data/links'; +import SyncedBlockUsage from './SyncedBlockUsage.svelte'; + +function instance(overrides: Partial = {}): Backlink { + return { + sourceDocumentId: 'doc-source', + sourceDocumentTitle: 'Source Doc', + sourceRecordId: 'block-1', + context: 'Some context text', + ...overrides + }; +} + +function baseProps( + overrides: Partial> = {} +): ComponentProps { + return { + spaceId: 'space-1', + currentDocumentId: 'doc-current', + instances: [instance()], + onJumpTo: vi.fn(), + ...overrides + }; +} + +// This is the one live surface using the navigate-to-`#block-`-and-reveal +// mechanism issue #83 asks for — the general page_link/wiki-link Backlinks +// panel that issue describes was removed (#120) pending a shard-aware +// reverse index (#81/#70), so this is where that navigation contract is +// actually exercised today. +describe('SyncedBlockUsage (#153, #83)', () => { + it('lists each usage as a real link to its exact source-block fragment', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + + const link = screen.getByRole('menuitem', { name: /Source Doc/ }); + expect(link).toHaveAttribute('href', expect.stringContaining('/space/space-1/doc/doc-source')); + expect(link.getAttribute('href')).toMatch(/#block-block-1$/); + }); + + it('jumps in place and suppresses navigation when the usage is in the current document', async () => { + const user = userEvent.setup(); + const props = baseProps({ + currentDocumentId: 'doc-source', + instances: [instance({ sourceDocumentId: 'doc-source' })] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: /Source Doc/ })); + + expect(props.onJumpTo).toHaveBeenCalledExactlyOnceWith('doc-source', 'block-1'); + }); + + // A real (not a JS-only handler) for a cross-document usage is + // what keeps browser Back and keyboard Enter/Space activation working for + // free — asserting onJumpTo is never called here is how this test proves + // the click was left to normal navigation instead of being intercepted. + it('does not intercept navigation when the usage is in a different document', async () => { + const user = userEvent.setup(); + const props = baseProps({ + currentDocumentId: 'doc-current', + instances: [instance({ sourceDocumentId: 'doc-other' })] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: /Source Doc/ })); + + expect(props.onJumpTo).not.toHaveBeenCalled(); + }); + + it('shows a placeholder instead of a link list when there are no other usages', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps({ instances: [] })); + + await user.click(screen.getByRole('button', { name: 'Used in 0 places' })); + + expect(screen.getByText('No other locations yet.')).toBeInTheDocument(); + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('ArrowDown/ArrowUp move roving focus among usage links, wrapping at each end', async () => { + const user = userEvent.setup(); + const props = baseProps({ + instances: [ + instance({ sourceRecordId: 'block-1', sourceDocumentTitle: 'First Doc' }), + instance({ sourceRecordId: 'block-2', sourceDocumentTitle: 'Second Doc' }) + ] + }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 2 places' })); + const first = screen.getByRole('menuitem', { name: /First Doc/ }); + const second = screen.getByRole('menuitem', { name: /Second Doc/ }); + expect(first).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(second).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(first).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + expect(second).toHaveFocus(); + }); + + it('offers Detach only when onDetach is provided, and calls it', async () => { + const user = userEvent.setup(); + const props = baseProps({ onDetach: vi.fn() }); + render(SyncedBlockUsage, props); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + await user.click(screen.getByRole('menuitem', { name: 'Detach to independent copy' })); + + expect(props.onDetach).toHaveBeenCalledOnce(); + }); + + it('does not offer Detach when onDetach is absent', async () => { + const user = userEvent.setup(); + render(SyncedBlockUsage, baseProps()); + + await user.click(screen.getByRole('button', { name: 'Used in 1 place' })); + + expect(screen.queryByRole('menuitem', { name: 'Detach to independent copy' })).toBeNull(); + }); +}); diff --git a/src/routes/space/[spaceId]/doc/[id]/page.svelte.test.ts b/src/routes/space/[spaceId]/doc/[id]/page.svelte.test.ts index f4d72a3..4d476f6 100644 --- a/src/routes/space/[spaceId]/doc/[id]/page.svelte.test.ts +++ b/src/routes/space/[spaceId]/doc/[id]/page.svelte.test.ts @@ -954,6 +954,62 @@ describe('doc/[id] +page', () => { const editor = document.querySelector(`#block-${target.id} [contenteditable]`) as HTMLElement; expect(document.activeElement).toBe(editor); }); + + // issue #83: the destination highlight must not rely on color alone — + // `outline`/`outline-2` are a shape/border change, not just the + // `outline-accent` color utility alongside them — and must be temporary. + it('gives the deep-linked block a temporary outline highlight, not color alone, then clears it', async () => { + Element.prototype.scrollIntoView = vi.fn(); + createDocument(ydoc, { id: 'doc-1', title: 'D' }); + const target = createRecord(ydoc, { parentId: 'doc-1', blockType: 'paragraph' }, HUMAN); + getRecordYText(ydoc, target.id)!.insert(0, 'Target'); + pageUrl.current = new URL(`http://localhost/space/space-1/doc/d1#block-${target.id}`); + + vi.useFakeTimers(); + try { + render(Page, { params: { spaceId: 'space-1', id: 'doc-1' }, form: null, data: pageData }); + // flushShardResolution's own real-timer wait doesn't apply under + // fake timers — advancing 0ms here settles the same async chain + // (shard resolution's two microtask awaits + Svelte's reactive + // flush) that a real setTimeout(0) would. + await vi.advanceTimersByTimeAsync(0); + await tick(); + + const row = document.querySelector(`#block-${target.id}`) as HTMLElement; + expect(row).toHaveClass('outline', 'outline-2', 'outline-accent'); + + await vi.advanceTimersByTimeAsync(1500); + await tick(); + + expect(row).not.toHaveClass('outline'); + expect(row).not.toHaveClass('outline-accent'); + } finally { + vi.useRealTimers(); + } + }); + + // issue #83: a stale (deleted) source-block reference must open the + // Document without throwing and without focusing/highlighting an + // unrelated block — `navigateToBlock`'s DOM lookup and blockRefs access + // are both optional-chained, so a missing id is a safe no-op rather than + // a special case that needs its own handling. + it('opens the Document without focusing or highlighting anything when the #block- target no longer exists', async () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + createDocument(ydoc, { id: 'doc-1', title: 'D' }); + const first = createRecord(ydoc, { parentId: 'doc-1', blockType: 'paragraph' }, HUMAN); + getRecordYText(ydoc, first.id)!.insert(0, 'First'); + pageUrl.current = new URL('http://localhost/space/space-1/doc/d1#block-does-not-exist'); + + render(Page, { params: { spaceId: 'space-1', id: 'doc-1' }, form: null, data: pageData }); + await flushShardResolution(); + await tick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(document.querySelector('[data-block-row]')).not.toHaveClass('outline'); + // The Document itself still rendered normally. + expect(document.querySelector(`#block-${first.id}`)).toBeInTheDocument(); + }); }); describe('columns block (#148)', () => { From 198b2f48f6b397bd5532a17c488009638d30014a Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sat, 12 Sep 2026 01:26:17 +0300 Subject: [PATCH 2/4] Implement exact-block backlink navigation (closes #83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds the Backlinks panel #120 removed, powered by a genuinely cross-shard-aware service function instead of the old client-side incremental index that could no longer span per-Document shards: - services/documents.ts#listBacklinks: fans out across every Document's own shard the same way search_workspace already does (#191's shared workspace-repository fan-out), scanning each with the existing stateless $lib/data/links.ts#listOutgoingLinks rather than any single Y.Doc's incremental reverse index. Permission-filtered like listDocuments; audited like get_document/search_workspace. Registered in the service-layer manifest (UI-only, mcp: false — #83's own scope doesn't ask for an MCP surface). - BacklinksPanel.svelte: renders each backlink as a real #block- link to the exact referring block, not just the referring Document. Reuses SyncedBlockUsage.svelte's established navigate/reveal/highlight contract: a same-document link short-circuits into an in-place scroll/focus/temporary-outline-highlight via the page's existing navigateToBlock, a cross-document link is a real (preserving browser Back and keyboard activation), and a stale/deleted source block degrades safely to just opening the Document (both lookups behind the highlight are optional-chained). Wired into +page.server.ts's load (SSR-only, not live, same accepted tradeoff as its existing documents/collections lists). This corrects course from the previous commit's test-coverage-only approach after Stop-hook feedback: the general Backlinks panel is not, in fact, blocked on #81/#70 — those block a *durable, rebuildable* reverse-link projection (#81's own explicit scope), but a working server-side scan was buildable now using infrastructure search_workspace already proved out (#191's fan-out helper), matching #81's own stated intent to "preserve the current in-memory index as the live UI path until the durable query path is available." Manually verified end-to-end in a real browser: creating a page_link from one Document to another shows it in the target's Backlinks panel, and clicking it navigates to the source Document with the exact block scrolled into view and highlighted (clearing after ~1.5s). Adds: - 8 new unit tests (services.test.ts) covering page_link and inline wiki-link discovery, cross-target exclusion, live source-title reflection, per-source-Document permission filtering (both denied and granted), denial for the target itself, and the audit entry. - 2 new Tier A cases (tests/e2e/tier-a.test.ts's manifest UI-wiring test, both the direct-service-call and harness-driven-route variants) exercising documents.listBacklinks through its real +page.server.ts binding. - docs/specifications/internal-links.md §5 rewritten to describe the new architecture in place of the removed one. Verified: npm run test (1258, was 1250), npm run test:e2e (28 Tier A + 6 Tier B), npm run test:coverage, npm run check, npm run lint — all pass. Manual browser verification via the real dev server. Closes #83 Co-Authored-By: Claude Sonnet 5 --- docs/specifications/internal-links.md | 8 +- src/lib/components/BacklinksPanel.svelte | 65 ++++++++++ src/lib/services/documents.ts | 79 +++++++++++- src/lib/services/manifest.ts | 6 + src/lib/services/services.test.ts | 118 ++++++++++++++++++ .../space/[spaceId]/doc/[id]/+page.server.ts | 10 +- .../space/[spaceId]/doc/[id]/+page.svelte | 23 +++- .../[id]/editing-conventions.svelte.test.ts | 3 +- .../[spaceId]/doc/[id]/page.server.test.ts | 9 +- .../[spaceId]/doc/[id]/page.svelte.test.ts | 63 ++++++++++ tests/e2e/tier-a.test.ts | 54 +++++++- 11 files changed, 422 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/BacklinksPanel.svelte diff --git a/docs/specifications/internal-links.md b/docs/specifications/internal-links.md index 17dad2a..d7bfc60 100644 --- a/docs/specifications/internal-links.md +++ b/docs/specifications/internal-links.md @@ -53,6 +53,10 @@ Before this feature, a `page_link` whose target had been deleted was indistingui ## 5. Backlinks -Opening a Document shows a Backlinks panel. It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a navigable referring Document title and the current text of the referring block as context. The panel is derived live from `listIncomingLinks`, so a source edit, rename, move, or deletion is immediately reflected; it never stores a title as link identity and cannot retarget a backlink when Documents have duplicate titles. +Opening a Document shows a Backlinks panel (`BacklinksPanel.svelte`). It lists every `page_link` block and inline `record:` wiki-link in the workspace whose target ID matches that Document, with a link to the exact referring block (not just the referring Document) and the current text of that block as context. -The current personal-MVP UI exposes every workspace Document to its sole local user. When workspace/space visibility boundaries are introduced, the panel must filter the `listIncomingLinks` result through the viewer's access scope before rendering; the reverse index itself remains an ID-based data primitive and must not become a permission side channel. +Powered by `services/documents.ts#listBacklinks`, not `$lib/data/links.ts#listIncomingLinks`'s client-side incremental index: that index is scoped to whatever single `Y.Doc` it's built against, which — since each Document has its own shard (#120) — can only ever be the current Document's own shard, structurally unable to span the workspace. The Backlinks panel built on it was removed for exactly this reason when #120 shipped. `listBacklinks` instead fans out across every Document's own shard server-side, the same `fanOutCatalogedAndUncataloged` pattern `search.ts#searchWorkspace` already established (#191), calling the stateless `listOutgoingLinks(doc, documentId)` scan once per fanned-out Document rather than relying on any single Y.Doc's incremental index. The load is SSR-only, not live (same accepted tradeoff `+layout.server.ts`'s own `documents`/`collections` lists already make) — a new backlink appears on next navigation/refresh, not instantly, since re-running a workspace-wide scan on every Yjs observer tick isn't proportionate. + +Each entry navigates via a `#block-` fragment on the referring Document's URL, reusing the same navigate/reveal/temporary-highlight mechanism `SyncedBlockUsage.svelte`'s "used in N places" panel already established: a same-document link short-circuits into an in-place scroll/focus/highlight (`navigateToBlock`) instead of a full reload, a cross-document link is a real `` (preserving browser Back and keyboard activation), and a stale/deleted source block degrades safely to just opening the Document, since both the DOM lookup and the block-ref lookup behind the highlight are optional-chained. + +`listBacklinks` is permission-filtered the same way `listDocuments` is: a source Document the caller can't reach never contributes a backlink entry, so a scoped MCP token (not currently exposed there — UI-only per its `serviceSurfaces` entry) couldn't use this to learn about content outside its grant if it ever were. The current personal-MVP UI's human caller is unscoped (Phase 0), so this is a no-op for the UI's own use — the load-bearing case is forward-looking. diff --git a/src/lib/components/BacklinksPanel.svelte b/src/lib/components/BacklinksPanel.svelte new file mode 100644 index 0000000..1c18031 --- /dev/null +++ b/src/lib/components/BacklinksPanel.svelte @@ -0,0 +1,65 @@ + + +
+
+ + + {backlinks.length} +
+ {#if backlinks.length > 0} +
+ {:else} +

No pages link here yet.

+ {/if} +
diff --git a/src/lib/services/documents.ts b/src/lib/services/documents.ts index f96f959..96eb653 100644 --- a/src/lib/services/documents.ts +++ b/src/lib/services/documents.ts @@ -4,6 +4,7 @@ import { createDocument as crdtCreateDocument, deleteDocument as crdtDeleteDocument, getDocument as crdtGetDocument, + listDocuments as crdtListDocuments, resolveChildPages, updateDocumentParent as crdtUpdateDocumentParent, updateDocumentTitle as crdtUpdateDocumentTitle @@ -11,8 +12,10 @@ import { import { getCollection as crdtGetCollection } from '$lib/data/collection-ops'; import { createRecord as crdtCreateRecord, + getRecord as crdtGetRecord, listRecordsForParent as crdtListRecordsForParent } from '$lib/data/record-ops'; +import { plainText } from '$lib/data/richtext'; import { logAudit } from '$lib/server/audit'; import { RecordIdConflictError, @@ -27,8 +30,16 @@ import { resolveShardForParent } from '$lib/server/catalog'; import { grantDocumentAccess, tokenAllowsParent } from '$lib/server/token-store'; -import { listWorkspaceDocuments } from '$lib/server/workspace-repository'; -import { resolveInternalLinkTarget, type InternalLinkTarget } from '$lib/data/links'; +import { + fanOutCatalogedAndUncataloged, + listWorkspaceDocuments +} from '$lib/server/workspace-repository'; +import { + listOutgoingLinks, + resolveInternalLinkTarget, + type Backlink, + type InternalLinkTarget +} from '$lib/data/links'; import type { CalloutStyle, ChildPageNode, @@ -593,6 +604,70 @@ export function getDocument( }; } +/** + * Every Document currently pointing at `documentId` via a `page_link` block + * or an inline `[[wiki link]]` (`internal-links.md` §5) — the reverse of + * `listOutgoingLinks`. Fanned out across every Document's own shard the same + * way `search.ts#searchWorkspace` scans for text matches (#191's shared + * workspace-repository fan-out): `$lib/data/links.ts#listIncomingLinks`'s own + * incremental index is scoped to whatever single `Y.Doc` it's built against + * and can't span per-Document shards on its own (#120) — see that module's + * doc comment, and the Backlinks panel removal note this replaces in + * `+page.svelte`. `listOutgoingLinks` itself has no such limitation: it's a + * plain one-shot scan of one already-resolved `doc`, which is exactly what + * this function calls it with, once per fanned-out Document. + * + * Permission-filtered the same way `listDocuments` is: a source Document the + * caller can't reach never contributes a backlink entry, per + * `internal-links.md` §5's requirement that a viewer only ever sees backlinks + * from Documents already within their own access scope. A no-op filter for + * Phase 0's unscoped human caller (`isAccessToken` is false), load-bearing + * once this is ever reachable by a scoped MCP token. + */ +export function listBacklinks(caller: CallerIdentity, documentId: string): Backlink[] { + requireAccessibleParent(caller, documentId, 'list_backlinks'); + const actor = actorForCaller(caller); + const { workspaceId, defaultSpaceId, doc: defaultDoc } = resolveWorkspaceContext(); + const allowed = (id: string, docSpaceId?: string) => + !isAccessToken(caller) || tokenAllowsParent(caller, id, docSpaceId); + + const backlinks: Backlink[] = []; + for (const { meta, doc } of fanOutCatalogedAndUncataloged({ + workspaceId, + defaultSpaceId, + defaultDoc, + listCatalog: listCatalogDocuments, + listUncataloged: crdtListDocuments, + getId: (m) => m.id, + getSpaceId: (m) => m.spaceId, + allowed, + resolveShardDoc: true + })) { + for (const link of listOutgoingLinks(doc, meta.id)) { + if (link.targetId !== documentId) continue; + const sourceRecord = crdtGetRecord(doc, link.sourceRecordId); + if (!sourceRecord) continue; + const trimmedContent = sourceRecord.content ? plainText(sourceRecord.content).trim() : ''; + backlinks.push({ + sourceDocumentId: meta.id, + sourceDocumentTitle: meta.title, + sourceRecordId: link.sourceRecordId, + context: + trimmedContent || + (sourceRecord.blockType === 'page_link' ? 'Page link' : 'Untitled block') + }); + } + } + + logAudit({ + actor, + action: 'list_backlinks', + targetRecordId: documentId, + diff: { count: backlinks.length } + }); + return backlinks; +} + /** * `spaceId` is optional and additive — omitted, this is exactly today's * behavior (every Document in the workspace, catalog plus uncataloged diff --git a/src/lib/services/manifest.ts b/src/lib/services/manifest.ts index 47a0fbe..c5db83d 100644 --- a/src/lib/services/manifest.ts +++ b/src/lib/services/manifest.ts @@ -80,6 +80,11 @@ export const serviceSurfaces: Record = mcpToolName: 'list_documents', mcpDescription: 'List Documents this connection has access to, including tree hierarchy.' }, + // UI-only for now (issue #83): the Document route's own load function + // renders a Backlinks panel from this. Not MCP-exposed — #83's own scope + // doesn't ask for an agent-facing surface, and this can be added later + // without touching the function's own contract. + 'documents.listBacklinks': { mcp: false, ui: true }, 'records.createRecord': { mcp: true, @@ -207,6 +212,7 @@ export const uiAdapterBindings = { // see docs/specifications/audit-coverage.md. 'documents.updateDocumentTitle': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'documents.listDocuments': 'src/routes/+layout.server.ts', + 'documents.listBacklinks': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts', 'records.createRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'records.writeRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', 'records.deleteRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', diff --git a/src/lib/services/services.test.ts b/src/lib/services/services.test.ts index 326778f..cf330b2 100644 --- a/src/lib/services/services.test.ts +++ b/src/lib/services/services.test.ts @@ -9,6 +9,7 @@ import { getDocument as servicesGetDocument, getRecord, holdRecords, + listBacklinks, listCollections, listDocuments, moveDocument, @@ -1275,6 +1276,123 @@ describe('service layer: documents — unfiltered listing, delete, and rename', }); }); +describe('service layer: listBacklinks — cross-shard reverse-link fan-out (issue #83)', () => { + it('finds a page_link block in a different Document pointing at the target', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + const block = createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + + const backlinks = listBacklinks(human, target.id); + + expect(backlinks).toHaveLength(1); + expect(backlinks[0]).toEqual({ + sourceDocumentId: source.id, + sourceDocumentTitle: 'Source Doc', + sourceRecordId: block.id, + context: 'Page link' + }); + }); + + it('finds an inline [[wiki link]] mark pointing at the target, using the block text as context', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + const block = createRecord(human, { parentId: source.id, blockType: 'paragraph' }); + writeRecord(human, block.id, { markdown: 'See [[Target Doc]] for more.' }); + + const backlinks = listBacklinks(human, target.id); + + expect(backlinks).toHaveLength(1); + expect(backlinks[0].sourceDocumentId).toBe(source.id); + expect(backlinks[0].sourceRecordId).toBe(block.id); + expect(backlinks[0].context).toContain('Target Doc'); + }); + + it('excludes links pointing at a different Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const other = createDocument(human, { title: 'Other Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: other.id + }); + + expect(listBacklinks(human, target.id)).toEqual([]); + }); + + it('reflects a source edit live — not a cached title/context', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + updateDocumentTitle(human, source.id, 'Renamed Source'); + + const backlinks = listBacklinks(human, target.id); + expect(backlinks[0].sourceDocumentTitle).toBe('Renamed Source'); + }); + + it('omits a backlink whose source Document a token was not granted', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + const { record: tokenRecord } = createToken({ + clientLabel: 'Scoped Backlink Reader', + allowedDocumentIds: [target.id], + allowedCollectionIds: [] + }); + + expect(listBacklinks(tokenRecord, target.id)).toEqual([]); + }); + + it('includes the backlink once the token is also granted the source Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const source = createDocument(human, { title: 'Source Doc' }); + createRecord(human, { + parentId: source.id, + blockType: 'page_link', + referencedRecordId: target.id + }); + const { record: tokenRecord } = createToken({ + clientLabel: 'Fully Scoped Backlink Reader', + allowedDocumentIds: [target.id, source.id], + allowedCollectionIds: [] + }); + + const backlinks = listBacklinks(tokenRecord, target.id); + expect(backlinks.some((b) => b.sourceDocumentId === source.id)).toBe(true); + }); + + it('is denied for a token without access to the target Document itself', () => { + const target = createDocument(human, { title: 'Target Doc' }); + const { record: tokenRecord } = createToken({ + clientLabel: 'No Access Bot', + allowedDocumentIds: [], + allowedCollectionIds: [] + }); + + expect(() => listBacklinks(tokenRecord, target.id)).toThrow(PermissionDeniedError); + }); + + it('logs an audit entry attributed to the target Document', () => { + const target = createDocument(human, { title: 'Target Doc' }); + listBacklinks(human, target.id); + + const entries = queryAuditLog().filter((e) => e.targetRecordId === target.id); + expect(entries.some((e) => e.action === 'list_backlinks')).toBe(true); + }); +}); + describe('service layer: collections — grants, listing, query, delete, rename', () => { it('a token creating a collection is granted access to it and can query it back', () => { const { record: tokenRecord } = createToken({ diff --git a/src/routes/space/[spaceId]/doc/[id]/+page.server.ts b/src/routes/space/[spaceId]/doc/[id]/+page.server.ts index 067a491..6f10650 100644 --- a/src/routes/space/[spaceId]/doc/[id]/+page.server.ts +++ b/src/routes/space/[spaceId]/doc/[id]/+page.server.ts @@ -1,7 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { resolve } from '$app/paths'; import { getDocument } from '$lib/data/document-ops'; -import { listDocuments, listCollections } from '$lib/services'; +import { listBacklinks, listDocuments, listCollections } from '$lib/services'; import { resolveParentWorkspaceContext } from '$lib/services/permissions'; import type { PageServerLoad } from './$types'; @@ -31,6 +31,12 @@ export const load: PageServerLoad = ({ params, locals }) => { // page_link target rendering. Not live, same accepted tradeoff as // Sidebar's lists. documents: listDocuments(locals.requestContext.caller), - collections: listCollections(locals.requestContext.caller) + collections: listCollections(locals.requestContext.caller), + // Backlinks panel (issue #83) — SSR-only, not live, same accepted + // tradeoff as documents/collections above: a workspace-wide fan-out + // scan (see documents.ts#listBacklinks) isn't something to re-run on + // every Yjs observer tick, so a new backlink appears on next + // navigation/refresh rather than instantly. + backlinks: listBacklinks(locals.requestContext.caller, params.id) }; }; diff --git a/src/routes/space/[spaceId]/doc/[id]/+page.svelte b/src/routes/space/[spaceId]/doc/[id]/+page.svelte index ae04f91..342b215 100644 --- a/src/routes/space/[spaceId]/doc/[id]/+page.svelte +++ b/src/routes/space/[spaceId]/doc/[id]/+page.svelte @@ -64,6 +64,7 @@ import PromptDialog from '$lib/components/PromptDialog.svelte'; import BlockActionMenu from '$lib/components/BlockActionMenu.svelte'; import SyncedBlockUsage from '$lib/components/SyncedBlockUsage.svelte'; + import BacklinksPanel from '$lib/components/BacklinksPanel.svelte'; import DocumentOutline from '$lib/components/DocumentOutline.svelte'; import type { PageProps } from './$types'; @@ -1633,12 +1634,24 @@
{holdAnnouncement}
+ { + if (documentId === data.documentId) void navigateToBlock(recordId); + }} + />