diff --git a/docs/SETUP.md b/docs/SETUP.md index 803f9c8..7ef1115 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -89,7 +89,7 @@ The model environment variable names follow the configured provider adapter. Pro Select a Space to open its page library. Search for a document, switch between grid and list views, or create a new page. The visual editor supports formatting, headings, lists, checklists, tables, and slash commands. Use `/` to insert a block and Cmd/Ctrl+S to save immediately. Pages autosave after editing pauses; the save status tells you whether changes reached the server. -Page actions include creating subpages, moving a page within its Space, and editing Markdown source. Existing documents with unsupported visual-editor syntax stay in source mode to preserve their content. Manual editing works without conversation credentials. +Page actions include creating subpages, moving a page within its Space, deleting a page (its subpages move up to the deleted page's parent), and editing Markdown source. Existing documents with unsupported visual-editor syntax stay in source mode to preserve their content. Manual editing works without conversation credentials. Open a page's chat and choose a specialist with access to that Space. Grant access from the Dot’s settings in the sidebar. The server creates or reuses a CopilotKit Thread for that page and specialist. The Dot receives the current saved page as context and can read, create, and edit pages in its authorized Spaces. The page conversation uses that page’s Space by default; other chats use the Dot’s default page destination. Save your manual edits before asking it to revise the document. Revision checks reject stale writes; a conflict keeps your local draft available for recovery. Failed saves stop automatic retries until you retry or resolve the conflict, so a disconnected session does not silently replace newer content. diff --git a/src/client/PageDocument.tsx b/src/client/PageDocument.tsx index 164b3bf..f5a35e6 100644 --- a/src/client/PageDocument.tsx +++ b/src/client/PageDocument.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { @@ -32,6 +33,7 @@ export function PageDocument({ onDirty, onSaved, onRefresh, + onDeleted, onSchedule, onThread, onSettings, @@ -47,6 +49,7 @@ export function PageDocument({ onDirty: (value: boolean) => void; onSaved: (page: Page) => void; onRefresh: () => void; + onDeleted: (id: string) => void; onSchedule: (id: string) => void; onThread: (id: string) => void; onSettings: () => void; @@ -57,6 +60,13 @@ export function PageDocument({ const [source, setSource] = useState(false); const [move, setMove] = useState(false); const [notice, setNotice] = useState(''); + const open = useRef(true); + useEffect(() => { + open.current = true; + return () => { + open.current = false; + }; + }, []); const [chatOpen, setChatOpen] = useState(false); const safety = useMemo(() => inspectMarkdown(draft.content), [draft.content]); const sourceMode = source || !safety.supported; @@ -200,6 +210,39 @@ export function PageDocument({ }, ] : []), + { + label: 'Delete page', + action: async () => { + if ( + !window.confirm( + `Delete "${draft.title || 'Untitled'}"? This can't be undone. Any subpages will move to this page's parent.`, + ) + ) + return; + try { + await api( + `/spaces/${page.spaceId}/pages/${page.id}`, + 'DELETE', + ); + onDeleted(page.id); + onRefresh(); + // A slow DELETE can finish after the user opened another + // page: only leave, and drop the pending autosave, if this + // document is still the one on screen. + if (open.current) { + controller.dispose(); + onDirty(false); + onHome(); + } + } catch (error) { + setNotice( + error instanceof Error + ? error.message + : 'Could not delete page.', + ); + } + }, + }, ]} /> diff --git a/src/client/PageReviewCard.tsx b/src/client/PageReviewCard.tsx index f7fa12c..01960d2 100644 --- a/src/client/PageReviewCard.tsx +++ b/src/client/PageReviewCard.tsx @@ -5,8 +5,10 @@ import remarkGfm from 'remark-gfm'; import { pageReviewSchema } from '../shared/page-review'; import { decidePageReview, + isDeletedReview, matchesReviewedDraft, restorePageReview, + type DeletedReview, } from './page-review-decision'; import { openPageLink } from './page-navigation'; import type { ReviewedPage } from '../server/pages'; @@ -28,25 +30,30 @@ export function PageReviewCard({ }) { const draft = pageReviewSchema.safeParse(args); const [savedPage, setSavedPage] = useState(); + const [deletedReview, setDeletedReview] = useState(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [receiptReady, setReceiptReady] = useState(false); const [restoreAttempt, setRestoreAttempt] = useState(0); const pending = useRef(false); const finished = status === 'complete'; - const conflict = !!savedPage && !matchesReviewedDraft(savedPage, args); - const saved = !!savedPage && !conflict; + const reviewed = savedPage ?? deletedReview; + const conflict = !!reviewed && !matchesReviewedDraft(reviewed, args); + const removed = !!deletedReview && !conflict; + const saved = (!!savedPage || removed) && !conflict; const pageId = savedPage?.id ?? ''; const spaceId = savedPage?.spaceId ?? ''; useEffect(() => { let active = true; setReceiptReady(false); setSavedPage(undefined); + setDeletedReview(undefined); setError(''); void restorePageReview(threadId, toolCallId) .then((page) => { if (!active) return; - setSavedPage(page ?? undefined); + if (isDeletedReview(page)) setDeletedReview(page); + else setSavedPage(page ?? undefined); setReceiptReady(true); }) .catch((cause) => { @@ -75,6 +82,19 @@ export function PageReviewCard({ }); return; } + if (isDeletedReview(page)) { + setSavedPage(undefined); + setDeletedReview(page); + await respond({ + approved: true, + pageId: page.pageId, + spaceId: page.spaceId, + deleted: true, + message: + 'The draft was saved, then the owner deleted the page. Do not link it.', + }); + return; + } setSavedPage(page); onSaved(); await respond({ @@ -102,13 +122,15 @@ export function PageReviewCard({ {conflict ? 'Review changed' - : saved - ? 'Saved to your Space' - : !receiptReady - ? 'Checking saved review…' - : finished - ? 'Review ended' - : 'Ready for your review'} + : removed + ? 'Saved, then deleted' + : saved + ? 'Saved to your Space' + : !receiptReady + ? 'Checking saved review…' + : finished + ? 'Review ended' + : 'Ready for your review'} {conflict diff --git a/src/client/SpaceWorkspace.tsx b/src/client/SpaceWorkspace.tsx index fcfede2..329c00b 100644 --- a/src/client/SpaceWorkspace.tsx +++ b/src/client/SpaceWorkspace.tsx @@ -1,5 +1,5 @@ import { mergePageSnapshot } from './page-snapshots'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Page } from '../server/pages'; import type { Space, WorkspaceState } from '../shared/types'; import { api } from './api'; @@ -32,6 +32,7 @@ export function SpaceWorkspace({ onCreateDot: () => void; }) { const [pages, setPages] = useState([]); + const removed = useRef(new Set()); const [error, setError] = useState(''); const [loaded, setLoaded] = useState(false); const [outline, setOutline] = useState(false); @@ -41,7 +42,9 @@ export function SpaceWorkspace({ try { const next = await api(`/spaces/${space.id}/pages`); if (active) { - setPages((previous) => mergePageSnapshot(previous, next)); + setPages((previous) => + mergePageSnapshot(previous, next, removed.current), + ); setLoaded(true); setError(''); } @@ -67,6 +70,16 @@ export function SpaceWorkspace({ ), [], ); + const deleted = useCallback((id: string) => { + removed.current.add(id); + setPages((previous) => { + const parentId = + previous.find((item) => item.id === id)?.parentId ?? null; + return previous + .filter((item) => item.id !== id) + .map((item) => (item.parentId === id ? { ...item, parentId } : item)); + }); + }, []); const create = async (parentId: string | null) => { try { const next = await api(`/spaces/${space.id}/pages`, 'POST', { @@ -122,6 +135,7 @@ export function SpaceWorkspace({ onSubpage={() => void create(page.id)} onDirty={onDirty} onSaved={saved} + onDeleted={deleted} onRefresh={onRefresh} onSchedule={onSchedule} onThread={onThread} diff --git a/src/client/page-review-decision.ts b/src/client/page-review-decision.ts index af32cb3..2af60d8 100644 --- a/src/client/page-review-decision.ts +++ b/src/client/page-review-decision.ts @@ -1,17 +1,31 @@ -import { pageReviewSchema } from '../shared/page-review'; +import { pageReviewSchema, type PageReviewDraft } from '../shared/page-review'; import type { ReviewedPage } from '../server/pages'; import { api } from './api'; const reviewPath = (threadId: string) => `/conversations/${encodeURIComponent(threadId)}/reviewed-page`; +export type DeletedReview = { + deleted: true; + pageId: string; + spaceId: string; + reviewDraft: PageReviewDraft | null; +}; +export const isDeletedReview = (value: unknown): value is DeletedReview => + !!value && + typeof value === 'object' && + (value as DeletedReview).deleted === true; + export function restorePageReview(threadId: string, toolCallId: string) { - return api( + return api( `${reviewPath(threadId)}/${encodeURIComponent(toolCallId)}`, ); } -export function matchesReviewedDraft(page: ReviewedPage, args: unknown) { +export function matchesReviewedDraft( + page: { reviewDraft: PageReviewDraft | null }, + args: unknown, +) { // Receipts created before draft binding have no original snapshot. if (!page.reviewDraft) return true; const draft = pageReviewSchema.safeParse(args); @@ -28,7 +42,7 @@ export async function decidePageReview( toolCallId: string, args: unknown, approved: boolean, -): Promise { +): Promise { // A previous save may have committed even if its response never arrived. const previous = await restorePageReview(threadId, toolCallId); if (previous) { diff --git a/src/client/page-snapshots.ts b/src/client/page-snapshots.ts index b2cdae9..91ffeae 100644 --- a/src/client/page-snapshots.ts +++ b/src/client/page-snapshots.ts @@ -1,10 +1,15 @@ import type { Page } from '../server/pages'; -/** Pages currently have no delete operation. A missing row in an older poll - * must not unmount a newly created document. Deletion would require explicit - * tombstones or mutation ordering, rather than interpreting absence here. */ -export function mergePageSnapshot(known: Page[], incoming: Page[]): Page[] { +/** A missing row in an older poll must not unmount a newly created document, + * so absence never removes a page. Deletions are explicit: ids in `removed` + * (tombstones kept by the caller) are ignored when a stale poll still lists them. */ +export function mergePageSnapshot( + known: Page[], + incoming: Page[], + removed: ReadonlySet = new Set(), +): Page[] { const pages = new Map(known.map((page) => [page.id, page])); for (const page of incoming) { + if (removed.has(page.id)) continue; const previous = pages.get(page.id); if (!previous || page.revision > previous.revision) pages.set(page.id, page); diff --git a/src/server/page-routes.ts b/src/server/page-routes.ts index 689a609..5718fdc 100644 --- a/src/server/page-routes.ts +++ b/src/server/page-routes.ts @@ -17,9 +17,13 @@ export function pageRoutes(platform: Platform) { { error: 'This Dot no longer has access to the selected Space.' }, 403, ); + const { pageId, spaceId, draft: reviewDraft } = receipt; + // The receipt outlives its page so a retried approval cannot recreate it. + if (!platform.workspace.pages.exists(spaceId, pageId)) + return c.json({ deleted: true, pageId, spaceId, reviewDraft }); return c.json({ - ...platform.workspace.pages.get(receipt.spaceId, receipt.pageId), - reviewDraft: receipt.draft, + ...platform.workspace.pages.get(spaceId, pageId), + reviewDraft, }); }); app.post('/conversations/:id/reviewed-page', async (c) => { @@ -93,6 +97,15 @@ export function pageRoutes(platform: Platform) { ), ); }); + app.delete('/spaces/:spaceId/pages/:id', (c) => { + const deleted = platform.workspace.pages.delete( + c.req.param('spaceId'), + c.req.param('id'), + ); + if (!deleted) + return c.json({ error: 'Page not found in this Space.' }, 404); + return c.json({ ok: true }); + }); app.post('/spaces/:spaceId/pages/:id/conversation', async (c) => { const data = z .object({ dotId: z.string().min(1) }) diff --git a/src/server/pages.ts b/src/server/pages.ts index ebe0464..5214e2c 100644 --- a/src/server/pages.ts +++ b/src/server/pages.ts @@ -73,6 +73,12 @@ export class Pages { if (!this.spaceExists(spaceId)) throw new PageError('Space not found.', 404); } + exists(spaceId: string, id: string): boolean { + this.requireSpace(spaceId); + return !!this.db + .prepare('SELECT 1 FROM pages WHERE id=? AND spaceId=?') + .get(id, spaceId); + } list(spaceId: string): Page[] { this.requireSpace(spaceId); return this.db @@ -276,4 +282,33 @@ export class Pages { ? this.get(spaceId ?? String(row.spaceId), String(row.pageId)) : undefined; } + delete(spaceId: string, id: string): boolean { + this.requireSpace(spaceId); + this.db.exec('BEGIN IMMEDIATE'); + try { + const page = this.db + .prepare('SELECT parentId FROM pages WHERE id=? AND spaceId=?') + .get(id, spaceId) as { parentId: string | null } | undefined; + if (!page) { + this.db.exec('COMMIT'); + return false; + } + const now = Date.now(); + this.db + .prepare( + 'UPDATE pages SET parentId=?, revision=revision+1, updatedAt=? WHERE spaceId=? AND parentId=?', + ) + .run(page.parentId, now, spaceId, id); + // page_reviews rows stay: a retried approval must not recreate this page. + this.db.prepare('DELETE FROM page_threads WHERE pageId=?').run(id); + this.db + .prepare('DELETE FROM pages WHERE id=? AND spaceId=?') + .run(id, spaceId); + this.db.exec('COMMIT'); + return true; + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } } diff --git a/tests/page-document-delete.test.tsx b/tests/page-document-delete.test.tsx new file mode 100644 index 0000000..6cc082e --- /dev/null +++ b/tests/page-document-delete.test.tsx @@ -0,0 +1,100 @@ +import { createElement } from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, expect, it, vi } from 'vitest'; +const api = vi.hoisted(() => vi.fn()); +const menu = vi.hoisted(() => ({ + items: [] as { label: string; action: () => unknown }[], +})); +vi.mock('../src/client/api', () => ({ api })); +vi.mock('../src/client/editor/DocumentMenu', () => ({ + DocumentMenu: ({ items }: { items: typeof menu.items }) => { + menu.items = items; + return null; + }, +})); +vi.mock('../src/client/editor/RichEditor', () => ({ default: () => null })); +vi.mock('../src/client/PageConversation', () => ({ + PageConversation: () => null, +})); +import { PageDocument } from '../src/client/PageDocument'; + +const page = { + id: 'a', + spaceId: 'space', + parentId: null, + title: 'A', + content: 'text', + revision: 1, + createdAt: 0, + updatedAt: 0, + sourceThreadId: null, +}; +const props = () => ({ + page, + pages: [page], + workspace: {} as never, + paused: false, + onHome: vi.fn(), + onOutline: vi.fn(), + onSubpage: vi.fn(), + onDirty: vi.fn(), + onSaved: vi.fn(), + onRefresh: vi.fn(), + onDeleted: vi.fn(), + onSchedule: vi.fn(), + onThread: vi.fn(), + onSettings: vi.fn(), + onCreateDot: vi.fn(), +}); +const deletion = () => + menu.items.find((item) => item.label === 'Delete page')!.action; + +beforeEach(() => { + api.mockReset(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('window', { + confirm: () => true, + addEventListener: () => {}, + removeEventListener: () => {}, + }); +}); + +it('leaves the page after its deletion finishes while it is still open', async () => { + const p = props(); + api.mockResolvedValue({ ok: true }); + await act(async () => { + create(createElement(PageDocument, p)); + }); + await act(async () => { + await deletion()(); + }); + expect(p.onDeleted).toHaveBeenCalledWith('a'); + expect(p.onRefresh).toHaveBeenCalledTimes(1); + expect(p.onHome).toHaveBeenCalledTimes(1); +}); + +it('ignores a stale deletion that finishes after the user left the page', async () => { + const p = props(); + let finish!: (value: unknown) => void; + api.mockReturnValue(new Promise((resolve) => (finish = resolve))); + let root!: ReturnType; + await act(async () => { + root = create(createElement(PageDocument, p)); + }); + let pending!: Promise; + await act(async () => { + pending = Promise.resolve(deletion()()); + }); + await act(async () => { + root.unmount(); + }); + const dirtyCalls = p.onDirty.mock.calls.length; + await act(async () => { + finish({ ok: true }); + await pending; + }); + expect(p.onDeleted).toHaveBeenCalledWith('a'); + expect(p.onRefresh).toHaveBeenCalledTimes(1); + expect(p.onHome).not.toHaveBeenCalled(); + expect(p.onDirty.mock.calls.length).toBe(dirtyCalls); +}); diff --git a/tests/page-review-card.test.tsx b/tests/page-review-card.test.tsx new file mode 100644 index 0000000..e78aee6 --- /dev/null +++ b/tests/page-review-card.test.tsx @@ -0,0 +1,70 @@ +import { createElement } from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, expect, it, vi } from 'vitest'; +const api = vi.hoisted(() => vi.fn()); +vi.mock('../src/client/api', () => ({ api })); +import { PageReviewCard } from '../src/client/PageReviewCard'; + +const draft = { title: 'Brief', content: 'Evidence', spaceId: 'space' }; +const text = (node: unknown): string => + typeof node === 'string' + ? node + : Array.isArray(node) + ? node.map(text).join('') + : node && typeof node === 'object' && 'children' in node + ? text((node as { children: unknown }).children) + : ''; + +beforeEach(() => { + api.mockReset(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); +}); + +it('drops the saved page when a refreshed receipt reports it deleted', async () => { + const saved = { + id: 'page', + spaceId: 'space', + parentId: null, + title: 'Brief', + content: 'Evidence', + revision: 1, + createdAt: 0, + updatedAt: 0, + sourceThreadId: null, + reviewDraft: draft, + }; + api.mockResolvedValueOnce(saved).mockResolvedValueOnce({ + deleted: true, + pageId: 'page', + spaceId: 'space', + reviewDraft: draft, + }); + const respond = vi.fn(async () => {}); + let root!: ReturnType; + await act(async () => { + root = create( + createElement(PageReviewCard, { + args: draft, + status: 'executing', + respond, + threadId: 'thread', + toolCallId: 'call', + onSaved: () => {}, + }), + ); + }); + expect(text(root.toJSON())).toContain('Open page'); + const button = root.root.findAll( + (node) => + node.type === 'button' && text(node.props.children).includes('Continue'), + )[0]; + await act(async () => { + await button.props.onClick(); + }); + const view = text(root.toJSON()); + expect(view).toContain('Saved, then deleted'); + expect(view).not.toContain('Open page'); + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ approved: true, deleted: true }), + ); +}); diff --git a/tests/page-review.test.tsx b/tests/page-review.test.tsx index 960a507..32990fb 100644 --- a/tests/page-review.test.tsx +++ b/tests/page-review.test.tsx @@ -2,7 +2,10 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { renderToStaticMarkup } from 'react-dom/server'; vi.mock('../src/client/api', () => ({ api: vi.fn() })); import { api } from '../src/client/api'; -import { decidePageReview } from '../src/client/page-review-decision'; +import { + decidePageReview, + isDeletedReview, +} from '../src/client/page-review-decision'; import { PageReviewCard } from '../src/client/PageReviewCard'; beforeEach(() => { @@ -25,6 +28,42 @@ it('recovers an already committed save instead of emitting a decline', async () expect(api).toHaveBeenCalledTimes(1); }); +it('recognizes a saved review whose page was deleted without saving again', async () => { + const draft = { title: 'Brief', content: 'Evidence', spaceId: 'space' }; + const deleted = { + deleted: true, + pageId: 'gone', + spaceId: 'space', + reviewDraft: draft, + }; + vi.mocked(api).mockResolvedValue(deleted); + const result = await decidePageReview('thread', 'call', draft, true); + expect(isDeletedReview(result)).toBe(true); + expect(result).toBe(deleted); + expect( + vi.mocked(api).mock.calls.filter((call) => call[1] === 'POST'), + ).toHaveLength(0); + expect(isDeletedReview({ id: 'page' })).toBe(false); + expect(isDeletedReview(null)).toBe(false); +}); + +it('rejects a changed draft for a review whose page was deleted', async () => { + vi.mocked(api).mockResolvedValue({ + deleted: true, + pageId: 'gone', + spaceId: 'space', + reviewDraft: { title: 'Brief', content: 'Evidence', spaceId: 'space' }, + }); + await expect( + decidePageReview( + 'thread', + 'call', + { title: 'Brief', content: 'Changed', spaceId: 'space' }, + true, + ), + ).rejects.toThrow('different draft'); +}); + it('does not decide or save when receipt recovery fails', async () => { vi.mocked(api).mockRejectedValue(new Error('Access revoked')); await expect(decidePageReview('thread', 'call', {}, false)).rejects.toThrow( diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index 19f3fb8..04e457f 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -269,6 +269,70 @@ it('restores review receipts through the owner API with current thread and Space ws.updateDot(dot.id, { ...dot, spaceId: other.id, spaceIds: [other.id] }); expect((await app.request(`${base}/call`, { headers })).status).toBe(403); }); + +it('deletes pages through the API and returns 404 for missing pages or spaces', async () => { + const { ws, app } = fixture(); + const space = ws.spaces()[0].id; + const page = ws.pages.create(space, { title: 'To Delete' }); + const path = `/api/spaces/${space}/pages/${page.id}`; + + const res = await app.request(path, request(undefined, 'DELETE')); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(ws.pages.list(space)).toHaveLength(0); + + const missing = await app.request(path, request(undefined, 'DELETE')); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + error: 'Page not found in this Space.', + }); + + const missingSpace = await app.request( + `/api/spaces/missing-space/pages/${page.id}`, + request(undefined, 'DELETE'), + ); + expect(missingSpace.status).toBe(404); + expect(await missingSpace.json()).toEqual({ error: 'Space not found.' }); +}); + +it('reports a deleted reviewed page instead of failing or recreating it', async () => { + const { ws, app } = fixture(); + const dot = ws.dots()[0]; + ws.bindThread('review-deleted', dot.id, 'Review'); + const base = '/api/conversations/review-deleted/reviewed-page'; + const saved = ws.pages.createReviewed( + dot.spaceId, + { title: 'Saved', content: 'Evidence' }, + 'review-deleted', + 'call', + ); + expect(ws.pages.delete(dot.spaceId, saved.id)).toBe(true); + expect(await (await app.request(`${base}/call`)).json()).toEqual({ + deleted: true, + pageId: saved.id, + spaceId: dot.spaceId, + reviewDraft: { + title: 'Saved', + content: 'Evidence', + spaceId: dot.spaceId, + }, + }); + const retry = await app.request( + base, + request( + { + title: 'Saved', + content: 'Evidence', + spaceId: dot.spaceId, + toolCallId: 'call', + }, + 'POST', + ), + ); + expect(retry.status).toBe(404); + expect(ws.pages.list(dot.spaceId)).toHaveLength(0); +}); + it.each([ ['GET', '/reviewed-page/tool'], ['POST', '/reviewed-page'], diff --git a/tests/page-snapshots.test.ts b/tests/page-snapshots.test.ts index c1ae460..4d72624 100644 --- a/tests/page-snapshots.test.ts +++ b/tests/page-snapshots.test.ts @@ -38,3 +38,10 @@ it('retains locally saved revisions while accepting newer remote pages', () => { .content, ).toBe('Latest'); }); + +it('ignores a stale poll that still lists a deleted page', () => { + const gone = { ...base, id: 'gone' }; + expect(mergePageSnapshot([base], [base, gone], new Set(['gone']))).toEqual([ + base, + ]); +}); diff --git a/tests/pages.test.ts b/tests/pages.test.ts index fd2b970..779ddd2 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -108,3 +108,72 @@ it('migrates review receipts and retains their original draft after restart', () store.close(); rmSync(dir, { recursive: true }); }); + +it('deletes a page and reparents descendants safely', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const space = store.spaces()[0].id; + const root = store.pages.create(space, { title: 'Root' }); + const child = store.pages.create(space, { + title: 'Child', + parentId: root.id, + }); + const grandChild = store.pages.create(space, { + title: 'Grandchild', + parentId: child.id, + }); + + // Verify deletion of child: grandChild should be reparented to root + expect(store.pages.delete(space, child.id)).toBe(true); + expect(store.pages.delete(space, child.id)).toBe(false); + expect(() => store.pages.get(space, child.id)).toThrow(); + expect(store.pages.get(space, grandChild.id).parentId).toBe(root.id); + // Reparenting bumps the revision so clients holding the old parent resync. + expect(store.pages.get(space, grandChild.id).revision).toBe( + grandChild.revision + 1, + ); + + // Verify deletion of root: grandChild should be reparented to null (root level) + expect(store.pages.delete(space, root.id)).toBe(true); + expect(store.pages.get(space, grandChild.id).parentId).toBeNull(); + expect(store.pages.list(space)).toHaveLength(1); + expect(store.pages.list(space)[0].id).toBe(grandChild.id); + + // Missing space throws + expect(() => store.pages.delete('non-existent-space', grandChild.id)).toThrow( + /Space not found/, + ); + store.close(); +}); + +it('drops page thread bindings on delete and never recreates a page from a retried review', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const space = store.spaces()[0].id; + const page = store.pages.create(space, { title: 'Draft' }); + store.pages.reserveThread(page.id, 'dot', 'thread-1'); + store.pages.finishThread(page.id, 'dot'); + expect(store.pages.thread(page.id, 'dot')).toBeDefined(); + expect(store.pages.delete(space, page.id)).toBe(true); + expect(store.pages.thread(page.id, 'dot')).toBeUndefined(); + + const reviewed = store.pages.createReviewed( + space, + { title: 'Reviewed', content: 'Reviewed content' }, + 'review-thread', + 'call-1', + ); + expect(store.pages.delete(space, reviewed.id)).toBe(true); + expect(store.pages.reviewReceipt('review-thread', 'call-1')).toMatchObject({ + pageId: reviewed.id, + spaceId: space, + }); + expect(() => + store.pages.createReviewed( + space, + { title: 'Reviewed', content: 'Reviewed content' }, + 'review-thread', + 'call-1', + ), + ).toThrow(/Page not found/); + expect(store.pages.list(space)).toHaveLength(0); + store.close(); +});