diff --git a/.changeset/content-reference-removal-label.md b/.changeset/content-reference-removal-label.md new file mode 100644 index 00000000000..b2788c9c00a --- /dev/null +++ b/.changeset/content-reference-removal-label.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Allow editors to label block removal by node type while preserving editor-local deletion. diff --git a/packages/toolkit/src/editor/DragHandle.spec.ts b/packages/toolkit/src/editor/DragHandle.spec.ts index a3bbc327804..7474553ac61 100644 --- a/packages/toolkit/src/editor/DragHandle.spec.ts +++ b/packages/toolkit/src/editor/DragHandle.spec.ts @@ -186,6 +186,23 @@ afterEach(() => { }); describe("DragHandle menu", () => { + it("uses the host removal label while deleting only the selected block", () => { + const getDeleteLabel = vi.fn(() => "Remove reference"); + const { editor, handle } = mountEditor( + "

Reference

Target remains

", + { getDeleteLabel }, + ); + try { + clickHandle(handle); + expect(getDeleteLabel).toHaveBeenCalledWith(editor.state.doc.firstChild); + clickMenuItem("Remove reference"); + expect(editor.state.doc.childCount).toBe(1); + expect(childText(editor, 0)).toBe("Target remains"); + } finally { + editor.destroy(); + } + }); + it("opens the block menu on a single click", () => { const { editor, handle } = mountEditor("

First

Second

"); diff --git a/packages/toolkit/src/editor/DragHandle.ts b/packages/toolkit/src/editor/DragHandle.ts index 8f59734e986..ffa6ab71703 100644 --- a/packages/toolkit/src/editor/DragHandle.ts +++ b/packages/toolkit/src/editor/DragHandle.ts @@ -31,6 +31,8 @@ export interface DragHandleOptions { * unchanged. */ wrapperSelector: string; + /** Override a block's removal label without changing its editor-local deletion. */ + getDeleteLabel?: (node: ProseMirrorNode) => string | undefined; /** * Optional source-side payload for a cross-editor block move. The editor doc * carries ProseMirror node content, but app-owned side-map data (for example a @@ -172,6 +174,7 @@ type DragHandleMenuContext = { type DragHandleRegistration = { view: EditorView; wrapperSelector: string; + getDeleteLabel?: DragHandleOptions["getDeleteLabel"]; getDragTransferData?: DragHandleOptions["getDragTransferData"]; receiveDragTransferData?: DragHandleOptions["receiveDragTransferData"]; handleDrop?: DragHandleOptions["handleDrop"]; @@ -518,6 +521,7 @@ export const DragHandle = Extension.create({ addProseMirrorPlugins() { const editor = this.editor; const wrapperSelector = this.options.wrapperSelector; + const getDeleteLabel = this.options.getDeleteLabel; const getDragTransferData = this.options.getDragTransferData; const receiveDragTransferData = this.options.receiveDragTransferData; const handleDrop = this.options.handleDrop; @@ -888,9 +892,14 @@ export const DragHandle = Extension.create({ DRAG_HANDLE_MENU_ICON_DUPLICATE, duplicateBlock, ), - createMenuItem("Delete", DRAG_HANDLE_MENU_ICON_DELETE, deleteBlock, { - danger: true, - }), + createMenuItem( + registrationForView(resolved.view)?.getDeleteLabel?.( + resolved.sourceNode, + ) ?? "Delete", + DRAG_HANDLE_MENU_ICON_DELETE, + deleteBlock, + { danger: true }, + ), createMenuItem( "Insert block below", DRAG_HANDLE_MENU_ICON_INSERT, @@ -1368,6 +1377,7 @@ export const DragHandle = Extension.create({ const registration: DragHandleRegistration = { view: editorView, wrapperSelector, + getDeleteLabel, getDragTransferData, receiveDragTransferData, handleDrop, diff --git a/templates/content/actions/_database-row-batch.ts b/templates/content/actions/_database-row-batch.ts index 9a4bf0bebf9..8e292fdfa41 100644 --- a/templates/content/actions/_database-row-batch.ts +++ b/templates/content/actions/_database-row-batch.ts @@ -192,7 +192,6 @@ export async function renumberDatabaseRows( const rows = await db .select({ id: schema.contentDatabaseItems.id, - documentId: schema.contentDatabaseItems.documentId, }) .from(schema.contentDatabaseItems) .innerJoin( @@ -209,7 +208,6 @@ export async function renumberDatabaseRows( if (rows.length === 0) return; const itemIds = rows.map((row) => row.id); - const documentIds = rows.map((row) => row.documentId); await db .update(schema.contentDatabaseItems) .set({ @@ -226,22 +224,4 @@ export async function renumberDatabaseRows( inArray(schema.contentDatabaseItems.id, itemIds), ), ); - - await db - .update(schema.documents) - .set({ - position: positionCaseSql( - schema.documents.id, - schema.documents.position, - documentIds, - ), - updatedAt: now, - }) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - inArray(schema.documents.id, documentIds), - ), - ); } diff --git a/templates/content/actions/_database-row-mutation.ts b/templates/content/actions/_database-row-mutation.ts index 50e2d763086..3b5442d65a7 100644 --- a/templates/content/actions/_database-row-mutation.ts +++ b/templates/content/actions/_database-row-mutation.ts @@ -961,7 +961,7 @@ async function withMutationLocks( run: () => Promise, ): Promise { return withPositionLock( - documentsPositionScope(database.ownerEmail, database.documentId), + documentsPositionScope(database.ownerEmail, null), () => withPositionLock(databaseItemsPositionScope(database.id), run), ); } @@ -985,7 +985,7 @@ async function createInsideTransaction( .where( and( eq(schema.documents.ownerEmail, context.database.ownerEmail), - eq(schema.documents.parentId, context.database.documentId), + isNull(schema.documents.parentId), ), ); const [maxItem] = await tx @@ -1005,7 +1005,7 @@ async function createInsideTransaction( spaceId: context.database.spaceId, ownerEmail: context.database.ownerEmail, orgId: context.database.orgId, - parentId: context.database.documentId, + parentId: null, title: args.title?.trim() ?? "", content: "", icon: null, diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 1665d9e908e..6a1546520fa 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -6664,7 +6664,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { // document or the same database can't read the same MAX (see // _position-utils.ts). return withPositionLock( - documentsPositionScope(args.database.ownerEmail, args.database.documentId), + documentsPositionScope(args.database.ownerEmail, null), () => withPositionLock( databaseItemsPositionScope(args.database.id), @@ -6675,7 +6675,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { .where( and( eq(schema.documents.ownerEmail, args.database.ownerEmail), - eq(schema.documents.parentId, args.database.documentId), + isNull(schema.documents.parentId), ), ); const [maxItemPos] = await db @@ -6734,7 +6734,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { spaceId: databaseSpaceId, ownerEmail: args.database.ownerEmail, orgId: args.database.orgId, - parentId: args.database.documentId, + parentId: null, title, content: "", icon: null, diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 38a371f6009..ea5cd75203c 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1279,22 +1279,7 @@ export async function isSoftDeletedDatabaseDocument(documentId: string) { sql`${schema.contentDatabases.deletedAt} IS NOT NULL`, ), ); - if (ownedDatabase) return true; - - const [databaseItem] = await db - .select({ id: schema.contentDatabaseItems.id }) - .from(schema.contentDatabaseItems) - .innerJoin( - schema.contentDatabases, - eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), - ) - .where( - and( - eq(schema.contentDatabaseItems.documentId, documentId), - sql`${schema.contentDatabases.deletedAt} IS NOT NULL`, - ), - ); - return !!databaseItem; + return !!ownedDatabase; } export async function getDatabaseByDocumentId( diff --git a/templates/content/actions/_document-discovery-query.ts b/templates/content/actions/_document-discovery-query.ts index 0d2926a3030..28428037c1f 100644 --- a/templates/content/actions/_document-discovery-query.ts +++ b/templates/content/actions/_document-discovery-query.ts @@ -36,10 +36,6 @@ export function softDeletedDatabaseDocumentExclusions(documentId: SQLWrapper) { schema.contentDatabases, "deleted_database_document_exclusions", ); - const deletedDatabaseItems = alias( - schema.contentDatabaseItems, - "deleted_database_membership_exclusions", - ); const deletedDatabaseDocument = db .select({ id: deletedDatabases.id }) .from(deletedDatabases) @@ -49,24 +45,7 @@ export function softDeletedDatabaseDocumentExclusions(documentId: SQLWrapper) { isNotNull(deletedDatabases.deletedAt), ), ); - const deletedDatabaseMembership = db - .select({ id: deletedDatabaseItems.id }) - .from(deletedDatabaseItems) - .innerJoin( - deletedDatabases, - eq(deletedDatabases.id, deletedDatabaseItems.databaseId), - ) - .where( - and( - eq(deletedDatabaseItems.documentId, documentId), - isNotNull(deletedDatabases.deletedAt), - ), - ); - - return [ - notExists(deletedDatabaseDocument), - notExists(deletedDatabaseMembership), - ] as const; + return [notExists(deletedDatabaseDocument)] as const; } export function documentDiscoveryWhere({ diff --git a/templates/content/actions/_document-lifecycle.ts b/templates/content/actions/_document-lifecycle.ts new file mode 100644 index 00000000000..2f64f03dd38 --- /dev/null +++ b/templates/content/actions/_document-lifecycle.ts @@ -0,0 +1,42 @@ +import { ActionContractError } from "@agent-native/core"; +import { eq, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; + +type Db = ReturnType; + +export function documentTrashedError() { + return new ActionContractError("This page is in Trash.", { + errorCode: "DOCUMENT_TRASHED", + statusCode: 409, + }); +} + +// Acquire database and membership locks first. Individual updates impose the +// same document lock order on PostgreSQL and SQLite, unlike an IN predicate. +export async function lockDocumentsForLifecycle(db: Db, documentIds: string[]) { + const documents = []; + for (const id of [...new Set(documentIds)].sort()) { + const [document] = await db + .update(schema.documents) + .set({ updatedAt: sql`${schema.documents.updatedAt}` }) + .where(eq(schema.documents.id, id)) + .returning(); + if (!document) { + throw new ActionContractError("Document not found.", { + errorCode: "DOCUMENT_NOT_FOUND", + statusCode: 404, + }); + } + documents.push(document); + } + return documents; +} + +export async function lockLiveDocuments(db: Db, documentIds: string[]) { + const documents = await lockDocumentsForLifecycle(db, documentIds); + if (documents.some((document) => document.trashedAt !== null)) { + throw documentTrashedError(); + } + return documents; +} diff --git a/templates/content/actions/_document-mutation-access.ts b/templates/content/actions/_document-mutation-access.ts new file mode 100644 index 00000000000..8cd1c038caf --- /dev/null +++ b/templates/content/actions/_document-mutation-access.ts @@ -0,0 +1,103 @@ +import { ActionContractError } from "@agent-native/core"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, asc, inArray, isNull, or, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { chunks } from "./_batch-utils.js"; +import { getContentOrganizationMembership } from "./_content-space-access.js"; + +// Call after locking documents. Holding existing grants prevents a concurrent +// revoke from racing the authorization check and the subsequent mutation. +export async function assertDocumentMutationAccess( + db: ReturnType, + documentIds: string[], + role: "viewer" | "editor" | "admin", +) { + const ids = [...new Set(documentIds)].sort(); + for (const batch of chunks(ids, 90)) { + await db + .select({ id: schema.documentShares.id }) + .from(schema.documentShares) + .where(inArray(schema.documentShares.resourceId, batch)) + .orderBy(asc(schema.documentShares.id)) + .for("share"); + let authorized = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, batch), + accessFilter( + schema.documents, + schema.documentShares, + undefined, + role, + { includePublic: role === "viewer" }, + ), + ), + ); + const userEmail = getRequestUserEmail(); + if (authorized.length !== batch.length && role === "viewer" && userEmail) { + const { orgMembers } = await import("@agent-native/core/org"); + const memberships = await db + .select({ orgId: orgMembers.orgId }) + .from(orgMembers) + .where( + and( + sql`LOWER(${orgMembers.email}) = ${userEmail.trim().toLowerCase()}`, + isNull(orgMembers.federationRemovalPendingAt), + ), + ) + .orderBy(asc(orgMembers.orgId)) + .for("share"); + const contexts = []; + for (const membership of memberships) { + if ( + await getContentOrganizationMembership(membership.orgId, userEmail, { + db, + }) + ) { + contexts.push({ userEmail, orgId: membership.orgId }); + } + } + if (contexts.length > 0) { + authorized = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, batch), + or( + accessFilter( + schema.documents, + schema.documentShares, + undefined, + role, + { includePublic: true }, + ), + ...contexts.map((context) => + accessFilter( + schema.documents, + schema.documentShares, + context, + role, + { includePublic: true }, + ), + ), + ), + ), + ); + } + } + if (authorized.length !== batch.length) { + throw new ActionContractError( + "You no longer have permission to change every page in this operation.", + { + errorCode: "DOCUMENT_MUTATION_ACCESS_CHANGED", + statusCode: 403, + }, + ); + } + } +} diff --git a/templates/content/actions/_document-trash-scope.ts b/templates/content/actions/_document-trash-scope.ts new file mode 100644 index 00000000000..a6e2a7478df --- /dev/null +++ b/templates/content/actions/_document-trash-scope.ts @@ -0,0 +1,70 @@ +import { ActionContractError } from "@agent-native/core"; +import { inArray } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { chunks } from "./_batch-utils.js"; + +type Db = ReturnType; + +export async function collectDocumentTrashScope( + db: Db, + rootId: string, + ownerEmail: string, +) { + const documentIds = new Set([rootId]); + const ownedDatabaseIds = new Set(); + let frontier = [rootId]; + while (frontier.length > 0) { + const next = new Set(); + for (const batch of chunks(frontier, 90)) { + const databases = await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.documentId, batch)); + const databaseParents = new Set( + databases.map((database) => database.documentId), + ); + for (const database of databases) ownedDatabaseIds.add(database.id); + const children = await db + .select({ + id: schema.documents.id, + parentId: schema.documents.parentId, + ownerEmail: schema.documents.ownerEmail, + }) + .from(schema.documents) + .where(inArray(schema.documents.parentId, batch)); + for (const child of children) { + if (databaseParents.has(child.parentId!)) { + // Legacy row creation stored collection membership as parentage, with + // no durable marker distinguishing it from a later deliberate move. + throw new ActionContractError( + "Database child ownership must be resolved before moving this page to Trash.", + { + errorCode: "DATABASE_CHILD_OWNERSHIP_AMBIGUOUS", + }, + ); + } + if (child.ownerEmail !== ownerEmail) { + throw new ActionContractError( + "The page hierarchy has a different owner and cannot be moved to Trash together.", + { + errorCode: "DOCUMENT_TRASH_SCOPE_OWNER_CONFLICT", + }, + ); + } + if (!documentIds.has(child.id)) { + documentIds.add(child.id); + next.add(child.id); + } + } + } + frontier = [...next]; + } + return { + documentIds: [...documentIds], + ownedDatabaseIds: [...ownedDatabaseIds], + }; +} diff --git a/templates/content/actions/blocks-seeding.db.test.ts b/templates/content/actions/blocks-seeding.db.test.ts index 0a050197260..d2afef82adb 100644 --- a/templates/content/actions/blocks-seeding.db.test.ts +++ b/templates/content/actions/blocks-seeding.db.test.ts @@ -219,7 +219,9 @@ describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", { db: tx, spaceId }, ), ), - ).rejects.toThrow(`No editor access to document ${documentId}`); + ).rejects.toMatchObject({ + errorCode: "DOCUMENT_MUTATION_ACCESS_CHANGED", + }); const databases = await db .select({ id: schema.contentDatabases.id }) @@ -228,7 +230,7 @@ describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", expect(databases).toEqual([]); }); }); - it("round-trips owned descriptions and returns one live root-to-database row context path", async () => { + it("round-trips owned descriptions without inheriting membership ancestors", async () => { const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const rootId = `root_${suffix}`; const now = new Date().toISOString(); @@ -330,10 +332,6 @@ describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", "Choose while active work is underway", ); expect(result.rowPage.contextPath).toEqual([ - expect.objectContaining({ - title: expect.stringMatching(/^Root /), - kind: "page", - }), expect.objectContaining({ title: expect.stringMatching(/^Tasks /), kind: "database", diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 56dfb33608a..09a6fcd74a2 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -2,6 +2,7 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { closeDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -37,6 +38,7 @@ let deleteDocumentAction: typeof import("./delete-document.js").default; let restoreDocumentAction: typeof import("./restore-document.js").default; let permanentlyDeleteDocumentAction: typeof import("./permanently-delete-document.js").default; let listTrashedDocumentsAction: typeof import("./list-trashed-documents.js").default; +let trashDocumentsAction: typeof import("./trash-documents.js").default; const OWNER = "owner@example.com"; const COLLABORATOR = "collaborator@example.com"; @@ -80,6 +82,7 @@ beforeAll(async () => { ).default; addDatabaseItemAction = (await import("./add-database-item.js")).default; deleteDocumentAction = (await import("./delete-document.js")).default; + trashDocumentsAction = (await import("./trash-documents.js")).default; restoreDocumentAction = (await import("./restore-document.js")).default; permanentlyDeleteDocumentAction = ( await import("./permanently-delete-document.js") @@ -90,7 +93,8 @@ beforeAll(async () => { await plugin(undefined as any); }, 60000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -956,6 +960,150 @@ describe("database-scoped document properties", () => { }); describe("document trash lifecycle", () => { + it("DEL-04 rejects ambiguous Database parentage without changing any page", async () => { + const { databaseId, databaseDocumentId } = await createDatabase({}); + const childId = await createDocument({ parentId: databaseDocumentId }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + deleteContentDatabaseAction.run({ databaseId }), + ), + ).rejects.toMatchObject({ + errorCode: "DATABASE_CHILD_OWNERSHIP_AMBIGUOUS", + }); + expect((await documentRow(childId)).trashedAt).toBeNull(); + expect((await documentRow(databaseDocumentId)).trashedAt).toBeNull(); + expect((await databaseRow(databaseId)).deletedAt).toBeNull(); + }); + + it("DEL-06 deduplicates selected ancestors and reports denied pages explicitly", async () => { + const rootId = await createDocument({ title: "Root" }); + const childId = await createDocument({ parentId: rootId, title: "Child" }); + const deniedId = await createDocument({ ownerEmail: COLLABORATOR }); + const result = await runWithRequestContext({ userEmail: OWNER }, () => + trashDocumentsAction.run({ ids: [childId, rootId, rootId, deniedId] }), + ); + expect(result.results.map(({ id, status }) => ({ id, status }))).toEqual([ + { id: childId, status: "covered" }, + { id: rootId, status: "trashed" }, + { id: deniedId, status: "failed" }, + ]); + expect(new Set(result.affectedDocumentIds)).toEqual( + new Set([rootId, childId]), + ); + const retry = await runWithRequestContext({ userEmail: OWNER }, () => + trashDocumentsAction.run({ ids: [rootId] }), + ); + expect(retry.results[0].status).toBe("already-trashed"); + expect(retry.affectedDocumentIds).toEqual([]); + }); + + it("DEL-13 checks each descendant's authority inside the trash transaction", async () => { + const rootId = await createDocument({ title: "Shared root" }); + const childId = await createDocument({ + parentId: rootId, + title: "Restricted child", + }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: rootId, + principalType: "user", + principalId: COLLABORATOR, + role: "admin", + createdBy: OWNER, + }); + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + deleteDocumentAction.run({ id: rootId }), + ), + ).rejects.toMatchObject({ errorCode: "DOCUMENT_MUTATION_ACCESS_CHANGED" }); + expect((await documentRow(rootId)).trashedAt).toBeNull(); + expect((await documentRow(childId)).trashedAt).toBeNull(); + }); + + it("DEL-04 preserves a Page in two databases when one database is trashed", async () => { + const first = await createDatabase({}); + const second = await createDatabase({}); + const documentId = await createDocument({ + title: "Shared member", + content: "Keep this body", + }); + const now = new Date().toISOString(); + await getDb() + .insert(schema.contentDatabaseItems) + .values( + [first, second].map(({ databaseId }) => ({ + id: nextId("membership"), + databaseId, + documentId, + ownerEmail: OWNER, + position: 0, + createdAt: now, + updatedAt: now, + })), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteContentDatabaseAction.run({ databaseId: first.databaseId }), + ); + expect(await documentRow(documentId)).toMatchObject({ + trashedAt: null, + parentId: null, + content: "Keep this body", + }); + expect((await databaseRow(second.databaseId)).deletedAt).toBeNull(); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + getDocumentAction.run({ id: first.databaseDocumentId }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + const databases = await runWithRequestContext({ userEmail: OWNER }, () => + listDocumentsAction.run({ documentType: "database", limit: 200 }), + ); + expect(databases.documents.map((document) => document.id)).not.toContain( + first.databaseDocumentId, + ); + + const readable = await runWithRequestContext({ userEmail: OWNER }, () => + getDocumentAction.run({ id: documentId }), + ); + expect(readable).toMatchObject({ + id: documentId, + content: "Keep this body", + }); + const listed = await runWithRequestContext({ userEmail: OWNER }, () => + listDocumentsAction.run({ exactTitle: "Shared member" }), + ); + expect(listed.documents).toContainEqual( + expect.objectContaining({ id: documentId }), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: documentId }), + ); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + listDocumentPropertiesAction.run({ + documentId, + databaseId: second.databaseId, + }), + ), + ).rejects.toMatchObject({ errorCode: "DOCUMENT_TRASHED", statusCode: 409 }); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: documentId }), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + restoreContentDatabaseAction.run({ databaseId: first.databaseId }), + ); + expect( + await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, documentId)), + ).toHaveLength(2); + }); + it("round-trips a page subtree without changing ids, bodies, or hierarchy", async () => { const rootId = await createDocument({ title: "Trash root", @@ -1509,10 +1657,10 @@ describe("content database soft-delete actions and reads", () => { const listedIds = new Set(listResponse.documents.map((doc) => doc.id)); expect(listedIds.has(hostDocumentId)).toBe(true); expect(listedIds.has(databaseDocumentId)).toBe(false); - expect(listedIds.has(rowDocumentId)).toBe(false); + expect(listedIds.has(rowDocumentId)).toBe(true); }); - it("hides soft-deleted database documents and rows from Files until restore", async () => { + it("hides soft-deleted database documents from Files while retaining live member Pages", async () => { const files = await createDatabase({ systemRole: "files" }); const hostDocumentId = await createDocument({ title: "Host" }); const ownerBlockId = nextId("inline_database"); @@ -1573,11 +1721,12 @@ describe("content database soft-delete actions and reads", () => { queryContentDatabaseItemsAction.run({ databaseId: files.databaseId }), ); expect(hidden.items.map((item) => item.document.id)).toEqual([ + rowDocumentId, retainedDocumentId, ]); expect(hidden.pagination).toMatchObject({ - totalItems: 1, - returnedItems: 1, + totalItems: 2, + returnedItems: 2, }); await runWithRequestContext({ userEmail: OWNER }, () => @@ -1780,7 +1929,7 @@ describe("content database soft-delete actions and reads", () => { expect(page.hydratedItemCount).toBe(1); }); - it("blocks direct document and property reads for soft-deleted database pages", async () => { + it("blocks deleted database reads while preserving live member document reads", async () => { const deletedAt = new Date().toISOString(); const { databaseId, databaseDocumentId } = await createDatabase({ deletedAt, @@ -1809,12 +1958,12 @@ describe("content database soft-delete actions and reads", () => { runWithRequestContext({ userEmail: OWNER }, () => getDocumentAction.run({ id: rowDocumentId }), ), - ).rejects.toThrow(`Document "${rowDocumentId}" not found`); + ).resolves.toMatchObject({ id: rowDocumentId }); await expect( runWithRequestContext({ userEmail: OWNER }, () => pullDocumentAction.run({ id: rowDocumentId, format: "markdown" }), ), - ).rejects.toThrow(`Document "${rowDocumentId}" not found`); + ).resolves.toMatchObject({ id: rowDocumentId }); await expect( runWithRequestContext({ userEmail: OWNER }, () => listDocumentPropertiesAction.run({ @@ -1822,7 +1971,7 @@ describe("content database soft-delete actions and reads", () => { databaseId, }), ), - ).rejects.toThrow(`Document "${rowDocumentId}" not found`); + ).rejects.toThrow(`Database "${databaseId}" not found`); }); it("reads one shared private database row's properties without exposing its Files container", async () => { diff --git a/templates/content/actions/create-content-database.ts b/templates/content/actions/create-content-database.ts index 73dd9cd999f..4c466915ccf 100644 --- a/templates/content/actions/create-content-database.ts +++ b/templates/content/actions/create-content-database.ts @@ -20,13 +20,20 @@ import type { ContentDatabaseResponse, CreateDatabaseRequest, } from "../shared/api.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { ensureDocumentFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { organizationContentSpaceId, provisionContentSpaces, } from "./_content-spaces.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; +import { + lockLiveDocuments, + documentTrashedError, +} from "./_document-lifecycle.js"; +import { assertDocumentMutationAccess } from "./_document-mutation-access.js"; import { documentsPositionScope, nextAppendPosition, @@ -150,6 +157,7 @@ export async function resolveContentDatabaseSpace( ): Promise { if (args.documentId) { const access = await assertAccess("document", args.documentId, "editor"); + if (access.resource.trashedAt) throw documentTrashedError(); const spaceId = (access.resource.spaceId as string | null) ?? (await healLegacyDocumentSpace(db, access.resource)); @@ -162,6 +170,7 @@ export async function resolveContentDatabaseSpace( } if (args.parentId) { const access = await assertAccess("document", args.parentId, "editor"); + if (access.resource.trashedAt) throw documentTrashedError(); const spaceId = (access.resource.spaceId as string | null) ?? (await healLegacyDocumentSpace(db, access.resource)); @@ -207,7 +216,45 @@ export async function createContentDatabaseRecord( resolveSpaceAccess?: typeof resolveContentSpaceAccess; } = {}, ): Promise { - const db = options.db ?? getDb(); + if (!options.db) { + return getDb().transaction((tx) => + createContentDatabaseRecord(args, { ...options, db: tx }), + ); + } + const db = options.db; + const targetDocumentId = args.documentId ?? args.parentId; + if (targetDocumentId) { + if (args.documentId) { + const existingDatabases = await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.documentId, args.documentId)); + const memberships = await db + .select({ + id: schema.contentDatabaseItems.id, + databaseId: schema.contentDatabaseItems.databaseId, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, args.documentId)); + const databaseIds = [ + ...new Set([ + ...existingDatabases.map((database: { id: string }) => database.id), + ...memberships.map( + (membership: { databaseId: string }) => membership.databaseId, + ), + ]), + ].sort(); + for (const databaseId of databaseIds) { + await lockContentDatabaseMutation(db, databaseId); + } + await lockDatabaseMemberships( + db, + memberships.map((membership: { id: string }) => membership.id), + ); + } + await lockLiveDocuments(db, [targetDocumentId]); + await assertDocumentMutationAccess(db, [targetDocumentId], "editor"); + } const now = new Date().toISOString(); let title = args.title?.trim() || ""; @@ -316,43 +363,48 @@ export async function createContentDatabaseRecord( // non-undefined narrowing from the guard above (`let` bindings lose // narrowing across a closure boundary). const resolvedOwnerEmail = ownerEmail; - await withPositionLock( - documentsPositionScope(resolvedOwnerEmail, parentId), - async () => { - const [maxPos] = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - parentId - ? and( - eq(schema.documents.ownerEmail, resolvedOwnerEmail), - eq(schema.documents.parentId, parentId), - ) - : and( - eq(schema.documents.ownerEmail, resolvedOwnerEmail), - sql`parent_id IS NULL`, - ), - ); + const insertDocument = async () => { + const [maxPos] = await db + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + parentId + ? and( + eq(schema.documents.ownerEmail, resolvedOwnerEmail), + eq(schema.documents.parentId, parentId), + ) + : and( + eq(schema.documents.ownerEmail, resolvedOwnerEmail), + sql`parent_id IS NULL`, + ), + ); - await db.insert(schema.documents).values({ - id: documentId!, - spaceId, - ownerEmail: resolvedOwnerEmail, - orgId, - parentId, - title, - content: "", - description: args.description?.trim() ?? "", - icon: null, - position: nextAppendPosition(maxPos?.max), - isFavorite: 0, - hideFromSearch, - visibility, - createdAt: now, - updatedAt: now, - }); - }, - ); + await db.insert(schema.documents).values({ + id: documentId!, + spaceId, + ownerEmail: resolvedOwnerEmail, + orgId, + parentId, + title, + content: "", + description: args.description?.trim() ?? "", + icon: null, + position: nextAppendPosition(maxPos?.max), + isFavorite: 0, + hideFromSearch, + visibility, + createdAt: now, + updatedAt: now, + }); + }; + if (parentId) { + await insertDocument(); + } else { + await withPositionLock( + documentsPositionScope(resolvedOwnerEmail, null), + insertDocument, + ); + } if (inheritedShares.length > 0) { await db.insert(schema.documentShares).values( diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index b7f03082d01..00c0038db65 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -21,6 +21,8 @@ import { import { ensureDocumentFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { provisionContentSpaces } from "./_content-spaces.js"; +import { lockLiveDocuments } from "./_document-lifecycle.js"; +import { assertDocumentMutationAccess } from "./_document-mutation-access.js"; import { documentsPositionScope, nextAppendPosition, @@ -203,14 +205,6 @@ export default defineAction({ visibility = parent.visibility ?? "private"; hideFromSearch = parent.hideFromSearch ?? 0; inheritedRole = parentAccess.role; - inheritedShares = await db - .select({ - principalType: schema.documentShares.principalType, - principalId: schema.documentShares.principalId, - role: schema.documentShares.role, - }) - .from(schema.documentShares) - .where(eq(schema.documentShares.resourceId, parentId)); } let spaceId: string; @@ -244,25 +238,51 @@ export default defineAction({ await withPositionLock( documentsPositionScope(ownerEmail, parentId), async () => { - // Get max position among siblings - const maxPos = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - parentId - ? and( - eq(schema.documents.ownerEmail, ownerEmail), - eq(schema.documents.parentId, parentId), - ) - : and( - eq(schema.documents.ownerEmail, ownerEmail), - sql`parent_id IS NULL`, - ), - ); + await db.transaction(async (tx) => { + if (parentId) { + const transactionDb = tx as unknown as ReturnType; + const [parent] = await lockLiveDocuments(transactionDb, [parentId]); + await assertDocumentMutationAccess( + transactionDb, + [parentId], + "editor", + ); + if ( + parent.spaceId !== spaceId || + parent.ownerEmail !== ownerEmail + ) { + throw new Error("Parent document changed; retry creation."); + } + orgId = parent.orgId; + visibility = parent.visibility; + hideFromSearch = parent.hideFromSearch; + inheritedShares = await tx + .select({ + principalType: schema.documentShares.principalType, + principalId: schema.documentShares.principalId, + role: schema.documentShares.role, + }) + .from(schema.documentShares) + .where(eq(schema.documentShares.resourceId, parentId)); + } + // Get max position among siblings + const maxPos = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + parentId + ? and( + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.parentId, parentId), + ) + : and( + eq(schema.documents.ownerEmail, ownerEmail), + sql`parent_id IS NULL`, + ), + ); - const position = nextAppendPosition(maxPos[0]?.max); + const position = nextAppendPosition(maxPos[0]?.max); - await db.transaction(async (tx) => { await tx.insert(schema.documents).values({ id, spaceId, diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index d348cd7b608..5edb3b23ebe 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -2,6 +2,7 @@ import { readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { closeDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -87,7 +88,8 @@ beforeAll(async () => { }); }, 60000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -710,6 +712,9 @@ describe("database row batch actions", () => { ), ).rejects.toThrow("All requested rows must exist in the target database"); expect(await orderedRows(databaseId)).toHaveLength(2); + expect( + (await orderedRows(databaseId)).map((row) => row.documentPosition), + ).toEqual([0, 3]); }); it("rejects removal from system databases whose memberships are canonical", async () => { @@ -1261,7 +1266,7 @@ describe("database row batch actions", () => { const source = readFileSync( new URL("./duplicate-document-property.ts", import.meta.url), "utf8", - ); + ).replace(/\r\n/g, "\n"); const transactionStart = source.indexOf( "await db.transaction(async (tx) => {", ); @@ -1333,7 +1338,7 @@ describe("database row batch actions", () => { for (const result of results) { expect(result.createdItem).toMatchObject({ id: result.receipt.row.itemId, - document: { id: result.receipt.row.documentId }, + document: { id: result.receipt.row.documentId, parentId: null }, }); const [createdDocument] = await getDb() .select({ updatedAt: schema.documents.updatedAt }) @@ -1359,15 +1364,12 @@ describe("database row batch actions", () => { Array.from({ length: concurrentAdds }, (_, index) => index), ); - const siblingDocPositions = await getDb() - .select({ position: schema.documents.position }) - .from(schema.documents) - .where(eq(schema.documents.parentId, databaseDocumentId)); - expect( - siblingDocPositions - .map((row: { position: number }) => row.position) - .sort((a: number, b: number) => a - b), - ).toEqual(Array.from({ length: concurrentAdds }, (_, index) => index)); + await expect( + getDb() + .select({ id: schema.documents.id }) + .from(schema.documents) + .where(eq(schema.documents.parentId, databaseDocumentId)), + ).resolves.toEqual([]); }); it("normalizes aggregate results before assigning the next position", () => { diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index a9d644e3311..f4856a2412b 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -1,5 +1,4 @@ import { defineAction } from "@agent-native/core/action"; -import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { z } from "zod"; @@ -8,6 +7,7 @@ import { assertContentDatabaseLifecycleAccess } from "./_content-database-lifecy import { lockDatabasesForTrash, trashDocumentSubtree, + accessibleAffectedDatabaseIds, } from "./delete-document.js"; export default defineAction({ @@ -24,29 +24,31 @@ export default defineAction({ await assertAccess("document", database.documentId, "admin"); const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); - await db.transaction(async (tx) => { + return db.transaction(async (tx) => { const transactionDb = tx as unknown as ReturnType; const lockedDatabaseIds = await lockDatabasesForTrash( transactionDb, database.documentId, database.ownerEmail, ); - return trashDocumentSubtree( + const affectedDocumentIds = await trashDocumentSubtree( transactionDb, database.documentId, database.ownerEmail, deletedAt, lockedDatabaseIds, ); + return { + success: true, + databaseId, + documentId: database.documentId, + deletedAt, + affectedDocumentIds, + affectedDatabaseIds: await accessibleAffectedDatabaseIds( + transactionDb, + affectedDocumentIds, + ), + }; }); - - await writeAppState("refresh-signal", { ts: Date.now() }); - - return { - success: true, - databaseId, - documentId: database.documentId, - deletedAt, - }; }, }); diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 91265724e2a..9f3332e240b 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,6 +1,5 @@ -import { defineAction } from "@agent-native/core/action"; -import { writeAppState } from "@agent-native/core/application-state"; -import { assertAccess } from "@agent-native/core/sharing"; +import { ActionContractError, defineAction } from "@agent-native/core/action"; +import { accessFilter, assertAccess } from "@agent-native/core/sharing"; import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; import { z } from "zod"; @@ -14,6 +13,9 @@ import { import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { renumberDatabaseRows } from "./_database-row-batch.js"; +import { lockDocumentsForLifecycle } from "./_document-lifecycle.js"; +import { assertDocumentMutationAccess } from "./_document-mutation-access.js"; +import { collectDocumentTrashScope } from "./_document-trash-scope.js"; const DELETE_BATCH_SIZE = 90; @@ -317,7 +319,7 @@ export async function lockDatabasesForTrash( id: string, ownerEmail: string, ) { - const subtree = await collectDocumentSubtreeForDelete(db, id, ownerEmail); + const subtree = await collectDocumentTrashScope(db, id, ownerEmail); const memberships = await selectMembershipsForDocuments( db, subtree.documentIds, @@ -341,8 +343,12 @@ export async function trashDocumentSubtree( trashedAt = new Date().toISOString(), lockedDatabaseIds?: ReadonlySet, ): Promise { - const { documentIds, ownedDatabaseIds } = - await collectDocumentSubtreeForDelete(db, id, ownerEmail); + lockedDatabaseIds ??= await lockDatabasesForTrash(db, id, ownerEmail); + const { documentIds, ownedDatabaseIds } = await collectDocumentTrashScope( + db, + id, + ownerEmail, + ); if (lockedDatabaseIds) { const memberships = await selectMembershipsForDocuments(db, documentIds); const unlockedDatabaseId = [ @@ -355,6 +361,22 @@ export async function trashDocumentSubtree( throw new Error("Document subtree changed; retry deletion."); } } + const scopeMemberships = await selectMembershipsForDocuments(db, documentIds); + await lockDatabaseMemberships( + db, + scopeMemberships.map((membership) => membership.id), + ); + await lockDocumentsForLifecycle(db, documentIds); + const lockedScope = await collectDocumentTrashScope(db, id, ownerEmail); + if ( + !hasSameIds(documentIds, lockedScope.documentIds) || + !hasSameIds(ownedDatabaseIds, lockedScope.ownedDatabaseIds) + ) { + throw new ActionContractError( + "The page hierarchy changed. Retry moving it to Trash.", + { errorCode: "DOCUMENT_TRASH_SCOPE_CHANGED" }, + ); + } await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); const independentlyTrashedDatabaseDocumentIds = new Set(); @@ -396,6 +418,7 @@ export async function trashDocumentSubtree( ); } + await assertDocumentMutationAccess(db, activeDocumentIds, "admin"); const activeMemberships = await selectMembershipsForDocuments( db, activeDocumentIds, @@ -927,9 +950,50 @@ export async function deleteTrashedDocumentSubtree( ); } +export async function accessibleAffectedDatabaseIds( + db: ReturnType, + documentIds: string[], +) { + if (documentIds.length === 0) return []; + const candidates = new Set(); + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + for (const membership of await selectMembershipsForDocuments(db, batch)) + candidates.add(membership.databaseId); + for (const database of await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.documentId, batch))) + candidates.add(database.id); + } + const visibleIds: string[] = []; + for (const batch of chunks([...candidates], DELETE_BATCH_SIZE)) { + const rows = await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabases.documentId), + ) + .where( + and( + inArray(schema.contentDatabases.id, batch), + accessFilter( + schema.documents, + schema.documentShares, + undefined, + "viewer", + { includePublic: true }, + ), + ), + ); + visibleIds.push(...rows.map((row) => row.id)); + } + return visibleIds; +} + export default defineAction({ description: - "Move a document and all its children to Trash. Use permanently-delete-document to destroy an item already in Trash.", + "Move a canonical page and its true child pages to Trash everywhere, preserving database memberships and references. Unpin with update-document isFavorite:false; remove memberships with remove-database-items. Use trash-documents for selected pages and permanently-delete-document only for irreversible removal from Trash.", schema: z.object({ id: z.string().optional().describe("Document ID (required)"), databaseDocumentId: z @@ -953,34 +1017,10 @@ export default defineAction({ ), ); if (contextDatabase) { - await assertAccess("document", contextDatabase.documentId, "editor"); - const [membership] = await db - .select({ id: schema.contentDatabaseItems.id }) - .from(schema.contentDatabaseItems) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, contextDatabase.id), - eq(schema.contentDatabaseItems.documentId, id), - ), - ); - if (!membership) { - throw new Error("Document is not part of Favorites"); - } - await db.transaction(async (tx) => { - await lockContentDatabaseMutation( - tx as unknown as ReturnType, - contextDatabase.id, - ); - await touchContentDatabase( - tx as unknown as ReturnType, - contextDatabase.id, - ); - await tx - .delete(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.id, membership.id)); - }); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { success: true, deleted: 0, removed: 1 }; + throw new ActionContractError( + "Use update-document with isFavorite:false to unpin, or omit databaseDocumentId to move the page to Trash.", + { errorCode: "DOCUMENT_DELETE_INTENT_REQUIRED" }, + ); } } @@ -993,24 +1033,29 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } - const deleted = await db.transaction(async (tx) => { + return db.transaction(async (tx) => { const transactionDb = tx as unknown as ReturnType; const lockedDatabaseIds = await lockDatabasesForTrash( transactionDb, id, existing.ownerEmail as string, ); - return trashDocumentSubtree( + const deleted = await trashDocumentSubtree( transactionDb, id, existing.ownerEmail as string, undefined, lockedDatabaseIds, ); + return { + success: true, + deleted: deleted.length, + affectedDocumentIds: deleted, + affectedDatabaseIds: await accessibleAffectedDatabaseIds( + transactionDb, + deleted, + ), + }; }); - - await writeAppState("refresh-signal", { ts: Date.now() }); - - return { success: true, deleted: deleted.length }; }, }); diff --git a/templates/content/actions/list-document-properties.ts b/templates/content/actions/list-document-properties.ts index 64e2057a579..3e7c06799b9 100644 --- a/templates/content/actions/list-document-properties.ts +++ b/templates/content/actions/list-document-properties.ts @@ -3,6 +3,7 @@ import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import { isSoftDeletedDatabaseDocument } from "./_database-utils.js"; +import { documentTrashedError } from "./_document-lifecycle.js"; import { listPropertiesForDocument, resolvePropertyDatabaseForDocument, @@ -26,6 +27,7 @@ export default defineAction({ run: async ({ documentId, databaseId }) => { const access = await resolveAccess("document", documentId); if (!access) throw new Error(`Document "${documentId}" not found`); + if (access.resource.trashedAt) throw documentTrashedError(); if (await isSoftDeletedDatabaseDocument(documentId)) { throw new Error(`Document "${documentId}" not found`); } diff --git a/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts b/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts index 61ee85f3233..34c5fcc8dd1 100644 --- a/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts +++ b/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts @@ -101,7 +101,7 @@ async function fixture() { id: documentId, ownerEmail: OWNER, spaceId: "synthetic_space", - parentId: databaseDocumentId, + parentId: null, title: "Synthetic row", content: "# Before", visibility: "private", @@ -462,7 +462,7 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { id: restoredDocumentId, ownerEmail: OWNER, spaceId: "synthetic_space", - parentId: seed.databaseDocumentId, + parentId: null, title: "Synthetic trashed row", content: "# Not migrated", visibility: "private", @@ -552,6 +552,11 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { await runWithRequestContext({ userEmail: OWNER }, () => deleteContentDatabase.run({ databaseId: seed.databaseId }), ); + // The original member survives Database trash; isolate the concurrently + // inserted membership whose scope rebuilding this test exercises. + await getDb() + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, seed.documentId)); let requestInsertion = () => {}; let releaseHolder = () => {}; @@ -580,7 +585,7 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { id: extraDocumentId, ownerEmail: OWNER, spaceId: "synthetic_space", - parentId: seed.databaseDocumentId, + parentId: null, title: "Concurrent synthetic row", content: "# Concurrent", visibility: "private", @@ -776,6 +781,10 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { await runWithRequestContext({ userEmail: OWNER }, () => deleteContentDatabase.run({ databaseId: seed.databaseId }), ); + // The preserved live member would reject purge before the lifecycle race. + await getDb() + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, seed.documentId)); let releaseHolder = () => {}; let holder: Promise | undefined; let restore: Promise | undefined; @@ -810,7 +819,7 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { () => null, (error: unknown) => error, ); - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForPostgresLockWait(2); releaseHolder(); await holder; await restore; @@ -863,7 +872,12 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { await getDb() .update(schema.documents) .set({ parentId: rootId }) - .where(eq(schema.documents.id, seed.databaseDocumentId)); + .where( + inArray(schema.documents.id, [ + seed.databaseDocumentId, + seed.documentId, + ]), + ); let releaseFlush = () => {}; let migration: Promise | undefined; let trash: Promise | undefined; @@ -982,6 +996,11 @@ postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { releaseGate(); await gateHolder; await trash; + await migrationExpectation; + // Membership removal is separate from trashing its Database. + await getDb() + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, seed.documentId)); permanentDelete = runWithRequestContext({ userEmail: OWNER }, () => permanentlyDeleteDocument.run({ id: seed.databaseDocumentId }), ); diff --git a/templates/content/actions/move-document.db.test.ts b/templates/content/actions/move-document.db.test.ts index 0119dd0730a..c781f025d4d 100644 --- a/templates/content/actions/move-document.db.test.ts +++ b/templates/content/actions/move-document.db.test.ts @@ -2,8 +2,9 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { closeDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; // move-document fires a `writeAppState("refresh-signal", …)` UI-refresh ping @@ -19,6 +20,39 @@ vi.mock("@agent-native/core/application-state", () => ({ writeAppState: vi.fn().mockResolvedValue(undefined), })); +const accessRace = vi.hoisted(() => ({ + afterAccess: null as null | ((id: string) => Promise), +})); +vi.mock("@agent-native/core/sharing", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + assertAccess: async (...args: Parameters) => { + const access = await actual.assertAccess(...args); + await accessRace.afterAccess?.(args[1]); + return access; + }, + }; +}); + +const positionRace = vi.hoisted(() => ({ + beforeLock: null as null | ((db: any) => Promise), +})); +vi.mock("./_document-lifecycle.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + lockLiveDocuments: async ( + ...args: Parameters + ) => { + await positionRace.beforeLock?.(args[0]); + return actual.lockLiveDocuments(...args); + }, + }; +}); + const TEST_DB_PATH = join( tmpdir(), `move-document-position-race-${process.pid}-${Date.now()}.pglite`, @@ -41,7 +75,8 @@ beforeAll(async () => { await plugin(undefined as any); }, 60000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -87,11 +122,121 @@ async function childPositions(parentId: string) { position: schema.documents.position, }) .from(schema.documents) - .where(eq(schema.documents.parentId, parentId)); + .where( + and( + eq(schema.documents.parentId, parentId), + eq(schema.documents.ownerEmail, OWNER), + ), + ); return rows as { id: string; position: number }[]; } describe("move-document position race", () => { + it.each(["source", "destination"])( + "rejects a move when the %s enters Trash after preflight", + async (target) => { + const id = await createDocument({ title: "Moving page" }); + const parentId = await createDocument({ title: "Destination" }); + const trashedId = target === "source" ? id : parentId; + accessRace.afterAccess = async (accessedId) => { + if (accessedId !== parentId) return; + accessRace.afterAccess = null; + await getDb() + .update(schema.documents) + .set({ trashedAt: new Date().toISOString(), trashRootId: trashedId }) + .where(eq(schema.documents.id, trashedId)); + }; + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + moveDocumentAction.run({ id, parentId }), + ), + ).rejects.toMatchObject({ errorCode: "DOCUMENT_TRASHED" }); + await expect( + getDb() + .select({ parentId: schema.documents.parentId }) + .from(schema.documents) + .where(eq(schema.documents.id, id)), + ).resolves.toEqual([{ parentId: null }]); + } finally { + accessRace.afterAccess = null; + } + }, + ); + + it("moves into a live parent without reordering its trashed children", async () => { + const parentId = await createDocument({ title: "Live destination" }); + const trashedId = await createDocument({ parentId, position: 7 }); + await getDb() + .update(schema.documents) + .set({ trashedAt: new Date().toISOString(), trashRootId: trashedId }) + .where(eq(schema.documents.id, trashedId)); + const id = await createDocument({ title: "Moving page" }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + moveDocumentAction.run({ id, parentId, position: 0 }), + ), + ).resolves.toMatchObject({ id, parentId, position: 0 }); + await expect( + getDb() + .select({ position: schema.documents.position }) + .from(schema.documents) + .where(eq(schema.documents.id, trashedId)), + ).resolves.toEqual([{ position: 7 }]); + }); + + it.each(["append", "reorder"])( + "refreshes %s positions when a child arrives before the parent lock", + async (mode) => { + const parentId = await createDocument({ title: "Parent" }); + const id = await createDocument({ title: "Moving page" }); + const arrivalId = nextId("arrival"); + positionRace.beforeLock = async (db) => { + positionRace.beforeLock = null; + const now = new Date().toISOString(); + await db.insert(schema.documents).values({ + id: arrivalId, + parentId, + ownerEmail: OWNER, + title: "New child", + content: "", + visibility: "private", + position: 0, + createdAt: now, + updatedAt: now, + }); + }; + try { + const result = runWithRequestContext({ userEmail: OWNER }, () => + moveDocumentAction.run({ + id, + parentId, + ...(mode === "reorder" ? { position: 0 } : {}), + }), + ); + if (mode === "append") { + await expect(result).resolves.toMatchObject({ + id, + parentId, + position: 1, + }); + } else { + await expect(result).rejects.toMatchObject({ + errorCode: "DOCUMENT_HIERARCHY_CHANGED", + }); + await expect( + getDb() + .select({ parentId: schema.documents.parentId }) + .from(schema.documents) + .where(eq(schema.documents.id, id)), + ).resolves.toEqual([{ parentId: null }]); + } + } finally { + positionRace.beforeLock = null; + } + }, + ); + it("rejects a parent in another Content space", async () => { const id = await createDocument({ spaceId: "space-one" }); const parentId = await createDocument({ spaceId: "space-two" }); diff --git a/templates/content/actions/move-document.ts b/templates/content/actions/move-document.ts index 4b7f35246be..e78fd5a1b59 100644 --- a/templates/content/actions/move-document.ts +++ b/templates/content/actions/move-document.ts @@ -1,7 +1,8 @@ +import { ActionContractError } from "@agent-native/core"; import { defineAction } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess, type Visibility } from "@agent-native/core/sharing"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -9,6 +10,9 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; +import { lockLiveDocuments } from "./_document-lifecycle.js"; +import { assertDocumentMutationAccess } from "./_document-mutation-access.js"; import { documentsPositionScope, nextAppendPosition, @@ -157,6 +161,7 @@ async function resolveSiblingPositionsAfterMove({ parentId ? and( eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.trashedAt), eq(schema.documents.parentId, parentId), ) : and( @@ -267,6 +272,105 @@ export default defineAction({ const runMoveTransaction = () => db.transaction(async (tx) => { + const transactionDb = tx as unknown as ReturnType; + if (blockDatabaseIdToDetach) { + await lockContentDatabaseMutation( + transactionDb, + blockDatabaseIdToDetach, + ); + } + const [detachedDatabase] = blockDatabaseIdToDetach + ? await tx + .select({ + ownerDocumentId: schema.contentDatabases.ownerDocumentId, + }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, blockDatabaseIdToDetach)) + : []; + const ownerDocumentId = detachedDatabase?.ownerDocumentId; + const lockedDocuments = await lockLiveDocuments(transactionDb, [ + id, + ...(ownerDocumentId ? [ownerDocumentId] : []), + ...(targetParentId ? [targetParentId] : []), + ...(normalizedSiblingPositions?.map((document) => document.id) ?? []), + ]); + await assertDocumentMutationAccess( + transactionDb, + [ + id, + ...(targetParentId ? [targetParentId] : []), + ...(ownerDocumentId ? [ownerDocumentId] : []), + ], + "editor", + ); + const current = lockedDocuments.find((document) => document.id === id)!; + if ( + current.ownerEmail !== ownerEmail || + current.spaceId !== existing.spaceId || + current.parentId !== existing.parentId || + !sameRootSection(current, existing) + ) { + throw new Error("Document changed; retry moving it."); + } + if (targetParentId) { + const parent = lockedDocuments.find( + (document) => document.id === targetParentId, + )!; + if ( + parent.ownerEmail !== ownerEmail || + parent.spaceId !== current.spaceId || + !sameRootSection(parent, current) + ) { + throw new Error("Parent document changed; retry moving it."); + } + await assertParentIsNotDescendant({ + db: transactionDb, + ownerEmail, + id, + parentId: targetParentId, + }); + } + if (args.position !== undefined) { + const currentSiblingPositions = + await resolveSiblingPositionsAfterMove({ + db: transactionDb, + ownerEmail, + id, + parentId: targetParentId, + rootSection: current, + position: args.position, + }); + const lockedIds = new Set( + lockedDocuments.map((document) => document.id), + ); + if ( + currentSiblingPositions.some( + (document) => !lockedIds.has(document.id), + ) + ) { + throw new ActionContractError( + "The page hierarchy changed. Retry moving it.", + { errorCode: "DOCUMENT_HIERARCHY_CHANGED", statusCode: 409 }, + ); + } + normalizedSiblingPositions = currentSiblingPositions; + updates.position = currentSiblingPositions.find( + (document) => document.id === id, + )!.position; + } else if (args.parentId !== undefined) { + const [maxPos] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + targetParentId + ? eq(schema.documents.parentId, targetParentId) + : and(rootSectionFilter(current), sql`parent_id IS NULL`), + ), + ); + updates.position = nextAppendPosition(maxPos?.max); + } await tx .update(schema.documents) .set(updates) @@ -339,22 +443,6 @@ export default defineAction({ await withPositionLock( documentsPositionScope(ownerEmail, parentId), async () => { - const maxPos = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - parentId - ? and( - eq(schema.documents.ownerEmail, ownerEmail), - eq(schema.documents.parentId, parentId), - ) - : and( - eq(schema.documents.ownerEmail, ownerEmail), - rootSectionFilter(existing), - sql`parent_id IS NULL`, - ), - ); - updates.position = nextAppendPosition(maxPos[0]?.max); await runMoveTransaction(); }, ); diff --git a/templates/content/actions/preview-content-database-source-attach.ts b/templates/content/actions/preview-content-database-source-attach.ts index 374b6521f90..d29e87146f5 100644 --- a/templates/content/actions/preview-content-database-source-attach.ts +++ b/templates/content/actions/preview-content-database-source-attach.ts @@ -56,7 +56,7 @@ export default defineAction({ databaseId: database.id, document: { id: ids.documentId, - parentId: database.documentId, + parentId: null, title: entry.title.trim() || entry.id, content: "", icon: null, diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index bdbe8cac801..8eab7568b0f 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -9,9 +9,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; // guard:allow-unscoped — isolated PGlite fixtures intentionally inspect rows directly. -import { getDbExec } from "@agent-native/core/db"; +import { closeDbExec, getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { and, eq, ne, or } from "drizzle-orm"; +import { and, eq, inArray, ne, or } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; @@ -743,7 +743,8 @@ afterEach(() => { builderReadMock.beforeSingleEntryRead = null; }); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -2049,13 +2050,21 @@ it("records freshly imported Builder row identities even when title and URL keys "entry-dup-2", ]); const documents = await db - .select({ title: schema.documents.title }) + .select({ + title: schema.documents.title, + parentId: schema.documents.parentId, + }) .from(schema.documents) - .where(eq(schema.documents.parentId, databaseDocId)); + .where( + inArray(schema.documents.id, [ + ...importResult.importedEntriesByDocumentId.keys(), + ]), + ); expect(documents.map((row: { title: string }) => row.title).sort()).toEqual([ "Best AI Coding Tools for Developers in 2024", "Best AI Coding Tools for Developers in 2024", ]); + expect(documents.every((row) => row.parentId === null)).toBe(true); const existingSourceRows = Array.from( importResult.importedEntriesByDocumentId.entries(), ).map(([documentId, entry], index) => ({ @@ -2087,7 +2096,11 @@ it("records freshly imported Builder row identities even when title and URL keys const retryDocuments = await db .select({ id: schema.documents.id }) .from(schema.documents) - .where(eq(schema.documents.parentId, databaseDocId)); + .where( + inArray(schema.documents.id, [ + ...importResult.importedEntriesByDocumentId.keys(), + ]), + ); const retryItems = await db .select({ id: schema.contentDatabaseItems.id }) .from(schema.contentDatabaseItems) @@ -2123,7 +2136,11 @@ it("records freshly imported Builder row identities even when title and URL keys const concurrentRetryDocuments = await db .select({ id: schema.documents.id }) .from(schema.documents) - .where(eq(schema.documents.parentId, databaseDocId)); + .where( + inArray(schema.documents.id, [ + ...importResult.importedEntriesByDocumentId.keys(), + ]), + ); const concurrentRetryItems = await db .select({ id: schema.contentDatabaseItems.id, @@ -2349,7 +2366,13 @@ it("repairs a legacy organization database into its organization space", async ( .where( or( eq(schema.documents.id, databaseDocumentId), - eq(schema.documents.parentId, databaseDocumentId), + inArray( + schema.documents.id, + db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)), + ), ), ); expect(repairedDatabase?.spaceId).toBe(expectedSpaceId); diff --git a/templates/content/actions/slack-correction-identity.db.test.ts b/templates/content/actions/slack-correction-identity.db.test.ts index eca506bdc46..104a7c8fa4d 100644 --- a/templates/content/actions/slack-correction-identity.db.test.ts +++ b/templates/content/actions/slack-correction-identity.db.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { serializePropertyOptions } from "../shared/properties.js"; @@ -188,7 +188,7 @@ describe("Content identity supporting Slack Design Ask corrections", () => { .where(eq(schema.documents.id, originalDocumentId)); expect(document).toMatchObject({ id: originalDocumentId, - parentId: seeded.databaseDocumentId, + parentId: null, title: "Human-renamed design ask", content: "Live design context edited after the Slack request.", }); @@ -208,7 +208,16 @@ describe("Content identity supporting Slack Design Ask corrections", () => { const rowDocuments = await db .select({ id: schema.documents.id }) .from(schema.documents) - .where(eq(schema.documents.parentId, seeded.databaseDocumentId)); + .innerJoin( + schema.contentDatabaseItems, + eq(schema.contentDatabaseItems.documentId, schema.documents.id), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, seeded.databaseId), + eq(schema.documents.ownerEmail, OWNER), + ), + ); expect(rowDocuments).toEqual([{ id: originalDocumentId }]); const values = await db diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts index 5ea39cf329e..df1a050e393 100644 --- a/templates/content/actions/space-aware-writers.db.test.ts +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -2,7 +2,7 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getDbExec } from "@agent-native/core/db"; +import { closeDbExec, getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -13,6 +13,22 @@ vi.mock("./_local-file-documents.js", async (importOriginal) => { return { ...original, isContentLocalFileMode: async () => false }; }); +const accessRace = vi.hoisted(() => ({ + afterAccess: null as null | ((id: string) => Promise), +})); +vi.mock("@agent-native/core/sharing", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + assertAccess: async (...args: Parameters) => { + const access = await actual.assertAccess(...args); + await accessRace.afterAccess?.(args[1]); + return access; + }, + }; +}); + const TEST_DB_PATH = join( tmpdir(), `space-aware-writers-${process.pid}-${Date.now()}.pglite`, @@ -55,7 +71,8 @@ beforeAll(async () => { )`); }, 60000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -101,6 +118,71 @@ async function filesMemberships(documentId: string) { } describe("space-aware document writers", () => { + it.each(["page", "database"])( + "rejects a %s child when its parent enters Trash after preflight", + async (kind) => { + const parent = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Live parent" }), + ); + const childId = "denied-child-" + kind; + accessRace.afterAccess = async (id) => { + if (id !== parent.id) return; + accessRace.afterAccess = null; + await getDb() + .update(schema.documents) + .set({ trashedAt: new Date().toISOString(), trashRootId: parent.id }) + .where(eq(schema.documents.id, parent.id)); + }; + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + kind === "page" + ? createDocument.run({ + id: childId, + title: "Denied child", + parentId: parent.id, + }) + : createContentDatabase.run({ + newDocumentId: childId, + title: "Denied child", + parentId: parent.id, + }), + ), + ).rejects.toMatchObject({ errorCode: "DOCUMENT_TRASHED" }); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, childId)), + ).resolves.toEqual([]); + } finally { + accessRace.afterAccess = null; + } + }, + ); + + it("creates a page and database beneath a live parent", async () => { + const parent = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Live child destination" }), + ); + const child = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Child", parentId: parent.id }), + ); + const database = await runWithRequestContext({ userEmail: OWNER }, () => + createContentDatabase.run({ + title: "Child database", + parentId: parent.id, + }), + ); + expect(child.parentId).toBe(parent.id); + await expect( + getDb() + .select({ parentId: schema.documents.parentId }) + .from(schema.documents) + .where(eq(schema.documents.id, database.database.documentId)), + ).resolves.toEqual([{ parentId: parent.id }]); + }); + it("rejects an empty caller-provided database document ID", async () => { await expect( runWithRequestContext({ userEmail: OWNER }, () => diff --git a/templates/content/actions/submit-content-database-form.db.test.ts b/templates/content/actions/submit-content-database-form.db.test.ts index 356d7be4f7d..0e573a2c491 100644 --- a/templates/content/actions/submit-content-database-form.db.test.ts +++ b/templates/content/actions/submit-content-database-form.db.test.ts @@ -2,9 +2,9 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getDbExec } from "@agent-native/core/db"; +import { closeDbExec, getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { serializePropertyOptions } from "../shared/properties.js"; @@ -55,7 +55,8 @@ beforeAll(async () => { }); }, 60_000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -218,6 +219,7 @@ describe("submit-content-database-form", () => { .from(schema.documents) .where(eq(schema.documents.id, result.createdDocumentId)); expect(document).toMatchObject({ + parentId: null, title: "Refresh the pricing page", content: "Clarify the enterprise story and update the hero.", visibility: "org", @@ -292,6 +294,21 @@ describe("submit-content-database-form", () => { })), ); + const rootDocuments = await getDb() + .select({ position: schema.documents.position }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, OWNER), + isNull(schema.documents.parentId), + ), + ); + const nextRootPosition = + Math.max( + -1, + ...rootDocuments.map((row: { position: number }) => row.position), + ) + 1; + const result = await runWithRequestContext({ userEmail: OWNER }, () => submitForm.run({ databaseId: seeded.databaseId, @@ -304,7 +321,10 @@ describe("submit-content-database-form", () => { }), ); const [document] = await db - .select({ position: schema.documents.position }) + .select({ + position: schema.documents.position, + parentId: schema.documents.parentId, + }) .from(schema.documents) .where(eq(schema.documents.id, result.createdDocumentId)); const [item] = await db @@ -318,7 +338,8 @@ describe("submit-content-database-form", () => { ); expect(result.verified).toBe(true); - expect(document?.position).toBe(0); + expect(document?.position).toBe(nextRootPosition); + expect(document?.parentId).toBeNull(); expect(item?.position).toBe(0); }); diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index 8656836707d..2bed1649e30 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -377,7 +377,7 @@ export default defineAction({ .where( and( eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), + isNull(schema.documents.parentId), ), ); const [maxItemPosition] = await tx @@ -398,7 +398,7 @@ export default defineAction({ spaceId: database.spaceId, ownerEmail: database.ownerEmail, orgId: database.orgId, - parentId: database.documentId, + parentId: null, title: normalizedTitle, content: documentContent, icon: null, diff --git a/templates/content/actions/trash-documents.ts b/templates/content/actions/trash-documents.ts new file mode 100644 index 00000000000..ea307ad9fcb --- /dev/null +++ b/templates/content/actions/trash-documents.ts @@ -0,0 +1,95 @@ +import { ActionContractError, defineAction } from "@agent-native/core/action"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import type { TrashPageResult, TrashPagesResult } from "../shared/api.js"; +import { collectDocumentTrashScope } from "./_document-trash-scope.js"; +import deleteDocument from "./delete-document.js"; + +function failedResult(id: string, error: unknown): TrashPageResult { + return { + id, + status: "failed", + affectedDocumentIds: [], + affectedDatabaseIds: [], + error: + error instanceof ActionContractError + ? { code: error.errorCode, message: error.message } + : { + code: "DOCUMENT_TRASH_FAILED", + message: + "The page could not be moved to Trash. Check access and retry.", + }, + }; +} + +export default defineAction({ + description: + "Move selected canonical pages and their true children to Trash. Deduplicates overlapping selection, authorizes each subtree, and returns an explicit outcome per page. Each subtree is atomic; other selected pages can succeed when one fails. Membership-only pages and reference targets are not descendants. Restore successful roots with restore-document to undo.", + schema: z.object({ + ids: z + .array(z.string().min(1)) + .min(1) + .max(100) + .describe("Canonical page IDs to move to Trash"), + }), + run: async ({ ids }): Promise => { + const uniqueIds = [...new Set(ids)]; + const results = new Map(); + const planned: Array<{ id: string; size: number }> = []; + for (const id of uniqueIds) { + try { + const access = await assertAccess("document", id, "admin"); + const scope = await collectDocumentTrashScope( + getDb(), + id, + access.resource.ownerEmail as string, + ); + planned.push({ id, size: scope.documentIds.length }); + } catch (error) { + results.set(id, failedResult(id, error)); + } + } + const affectedDocumentIds = new Set(); + const affectedDatabaseIds = new Set(); + for (const { id } of planned.sort((a, b) => b.size - a.size)) { + try { + await assertAccess("document", id, "admin"); + if (affectedDocumentIds.has(id)) { + results.set(id, { + id, + status: "covered", + affectedDocumentIds: [], + affectedDatabaseIds: [], + }); + continue; + } + const result = await deleteDocument.run({ id }); + result.affectedDocumentIds.forEach((documentId) => + affectedDocumentIds.add(documentId), + ); + result.affectedDatabaseIds.forEach((databaseId) => + affectedDatabaseIds.add(databaseId), + ); + results.set(id, { + id, + status: result.deleted > 0 ? "trashed" : "already-trashed", + affectedDocumentIds: result.affectedDocumentIds, + affectedDatabaseIds: result.affectedDatabaseIds, + }); + } catch (error) { + results.set(id, failedResult(id, error)); + } + } + return { + results: uniqueIds.map((id) => { + const result = results.get(id); + if (!result) throw new Error("A selected page has no trash outcome."); + return result; + }), + affectedDocumentIds: [...affectedDocumentIds], + affectedDatabaseIds: [...affectedDatabaseIds], + }; + }, +}); diff --git a/templates/content/actions/upsert-database-item-by-key.db.test.ts b/templates/content/actions/upsert-database-item-by-key.db.test.ts index f0c95cceaa0..25a0d5c4f86 100644 --- a/templates/content/actions/upsert-database-item-by-key.db.test.ts +++ b/templates/content/actions/upsert-database-item-by-key.db.test.ts @@ -2,11 +2,12 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { closeDbExec } from "@agent-native/core/db"; import { runFrameworkReleaseMigrations, runWithRequestContext, } from "@agent-native/core/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; @@ -65,7 +66,8 @@ beforeAll(async () => { await plugin(undefined as any); }, 60_000); -afterAll(() => { +afterAll(async () => { + await closeDbExec(); rmSync(TEST_DB_PATH, { force: true, recursive: true }); }); @@ -333,6 +335,12 @@ describe("reliable Content database row mutations", () => { readback: { verified: true, title: "Strict row" }, }); expect(result.receipt.row.rowRevision).toMatch(/^sha256:/); + await expect( + getDb() + .select({ parentId: schema.documents.parentId }) + .from(schema.documents) + .where(eq(schema.documents.id, result.receipt.row.documentId)), + ).resolves.toEqual([{ parentId: null }]); expect(result.receipt.readback.propertyValues).toMatchObject({ [propertyIds.number]: 42, [propertyIds.select]: "one", @@ -642,6 +650,21 @@ describe("reliable Content database row mutations", () => { })), ); + const rootDocuments = await getDb() + .select({ position: schema.documents.position }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, OWNER), + isNull(schema.documents.parentId), + ), + ); + const nextRootPosition = + Math.max( + -1, + ...rootDocuments.map((row: { position: number }) => row.position), + ) + 1; + const discovered = await contract(ids.databaseId); const firstInput = { ...envelope(discovered, "legacy-position-first"), @@ -675,12 +698,31 @@ describe("reliable Content database row mutations", () => { .from(schema.contentDatabaseItems) .where(eq(schema.contentDatabaseItems.databaseId, ids.databaseId)); const documentRows = await getDb() - .select({ id: schema.documents.id, position: schema.documents.position }) + .select({ + id: schema.documents.id, + position: schema.documents.position, + parentId: schema.documents.parentId, + }) .from(schema.documents) - .where(eq(schema.documents.parentId, ids.databaseDocumentId)); + .where( + inArray( + schema.documents.id, + itemRows.map((row: { documentId: string }) => row.documentId), + ), + ); expect(itemRows).toHaveLength(4); expect(documentRows).toHaveLength(4); + expect( + documentRows + .filter((row: { id: string }) => + [ + first.receipt.row.documentId, + second.receipt.row.documentId, + ].includes(row.id), + ) + .every((row: { parentId: string | null }) => row.parentId === null), + ).toBe(true); expect( itemRows.find( (row: { documentId: string }) => @@ -697,12 +739,12 @@ describe("reliable Content database row mutations", () => { documentRows.find( (row: { id: string }) => row.id === first.receipt.row.documentId, )?.position, - ).toBe(0); + ).toBe(nextRootPosition); expect( documentRows.find( (row: { id: string }) => row.id === second.receipt.row.documentId, )?.position, - ).toBe(1); + ).toBe(nextRootPosition + 1); }); it("denies receipt replay after row access is revoked", async () => { @@ -735,7 +777,7 @@ describe("reliable Content database row mutations", () => { title: "Before lock", }), ); - const scope = documentsPositionScope(OWNER, ids.databaseDocumentId); + const scope = documentsPositionScope(OWNER, null); let releaseLock!: () => void; let markAcquired!: () => void; const acquired = new Promise((resolve) => { @@ -796,7 +838,7 @@ describe("reliable Content database row mutations", () => { expect(created.receipt.readback.propertyValues[keyPropertyId]).toBe( "feedback-locked", ); - const scope = documentsPositionScope(OWNER, ids.databaseDocumentId); + const scope = documentsPositionScope(OWNER, null); let releaseLock!: () => void; let markAcquired!: () => void; const acquired = new Promise((resolve) => { diff --git a/templates/content/app/components/documents/PageTrashControl.test.tsx b/templates/content/app/components/documents/PageTrashControl.test.tsx new file mode 100644 index 00000000000..62a19eb79f7 --- /dev/null +++ b/templates/content/app/components/documents/PageTrashControl.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { pageTrashMessages } from "../../page-trash-messages"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { + PageTrashDialog, + PageTrashMenuItem, + PageTrashSelectionButton, + usePageTrashControl, +} from "./PageTrashControl"; + +const trash = vi.hoisted(() => vi.fn()); +const restore = vi.hoisted(() => vi.fn().mockResolvedValue({ success: true })); +const notice = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() })); +vi.mock("@/hooks/use-trash-pages", () => ({ + useTrashPages: () => ({ mutateAsync: trash, isPending: false }), +})); +vi.mock("@/hooks/use-documents", () => ({ + useRestoreDocument: () => ({ mutateAsync: restore }), +})); +vi.mock("sonner", () => ({ toast: notice })); +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string, args?: { count: number }) => { + const value = + pageTrashMessages["en-US"][ + key.replace( + "pageTrash.", + "", + ) as keyof (typeof pageTrashMessages)["en-US"] + ]; + return value.replace("{{count}}", String(args?.count ?? "")); + }, +})); + +let root: Root; +let host: HTMLDivElement; +function Harness() { + const control = usePageTrashControl({ + pages: [ + { id: "page-a", title: "Alpha" }, + { id: "page-b", title: "Beta" }, + ], + }); + return ( + <> + + + + ); +} +async function mount() { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => root.render()); + await act(async () => + (host.querySelector("button") as HTMLButtonElement).click(), + ); +} +async function confirm() { + const button = Array.from( + document.querySelectorAll('[role="dialog"] button'), + ).find((button) => /^Move pages? to Trash$/.test(button.textContent ?? "")); + expect(button).toBeDefined(); + await act(async () => (button as HTMLButtonElement).click()); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + host?.remove(); + vi.clearAllMocks(); +}); + +describe("page trash confirmation", () => { + it("counts affected child pages while Undo restores only the root", async () => { + trash.mockResolvedValueOnce({ + results: [ + { + id: "page-a", + status: "trashed", + affectedDocumentIds: ["page-a", "page-b"], + affectedDatabaseIds: [], + }, + { + id: "page-b", + status: "covered", + affectedDocumentIds: [], + affectedDatabaseIds: [], + }, + ], + affectedDocumentIds: ["page-a", "page-b"], + affectedDatabaseIds: [], + }); + await mount(); + await confirm(); + expect(notice.mock.calls[0][0]).toBe("Pages moved to Trash: 2"); + await act(async () => notice.mock.calls[0][1].action.onClick()); + expect(restore).toHaveBeenCalledExactlyOnceWith({ id: "page-a" }); + }); + + it("keeps the confirmation mounted when its row menu closes", async () => { + function RowMenu() { + const control = usePageTrashControl({ + pages: [{ id: "canonical-page", title: "" }], + }); + return ( + <> + + Actions + + + + + + + + + ); + } + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + await act(async () => root.render()); + const trigger = host.querySelector("button"); + const item = document.querySelector('[role="menuitem"]'); + expect(item?.textContent).toContain("Move page to Trash"); + await act(async () => (item as HTMLElement).click()); + expect(document.querySelector('[role="menu"]')).toBeNull(); + expect(document.querySelector('[role="dialog"] ul')?.textContent).toBe( + "Untitled", + ); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + "Move page to Trash?", + ); + expect( + document.querySelector('[role="dialog"]')?.textContent, + ).not.toContain("canonical-page"); + const cancel = Array.from( + document.querySelectorAll('[role="dialog"] button'), + ).find((button) => button.textContent === "Cancel"); + await act(async () => (cancel as HTMLButtonElement).click()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(document.activeElement).toBe(trigger); + }); + + it("keeps failures visible, retries only failed pages, and undoes only committed roots", async () => { + trash.mockResolvedValueOnce({ + results: [ + { + id: "page-a", + status: "trashed", + affectedDocumentIds: ["page-a"], + affectedDatabaseIds: [], + }, + { + id: "page-b", + status: "failed", + affectedDocumentIds: [], + affectedDatabaseIds: [], + error: { code: "forbidden", message: "Access changed" }, + }, + ], + affectedDocumentIds: ["page-a"], + affectedDatabaseIds: [], + }); + await mount(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + "trashed everywhere", + ); + await confirm(); + expect(trash).toHaveBeenCalledWith({ ids: ["page-a", "page-b"] }); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "Beta: Access changed", + ); + expect(document.querySelector('[role="dialog"] ul')?.textContent).toBe( + "Beta", + ); + const undo = notice.mock.calls[0][1].action.onClick; + await act(async () => undo()); + expect(restore).toHaveBeenCalledExactlyOnceWith({ id: "page-a" }); + trash.mockResolvedValueOnce({ + results: [ + { + id: "page-b", + status: "trashed", + affectedDocumentIds: ["page-b"], + affectedDatabaseIds: [], + }, + ], + affectedDocumentIds: ["page-b"], + affectedDatabaseIds: [], + }); + await confirm(); + expect(trash).toHaveBeenLastCalledWith({ ids: ["page-b"] }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("does not dismiss an unreadable mutation response or offer Undo", async () => { + trash.mockRejectedValueOnce(new Error("Network unavailable")); + await mount(); + await confirm(); + expect(document.querySelector('[role="alert"]')?.textContent).toBe( + "Could not trash pages.", + ); + expect(document.querySelector('[role="dialog"] ul')?.textContent).toBe( + "AlphaBeta", + ); + expect(notice).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/content/app/components/documents/PageTrashControl.tsx b/templates/content/app/components/documents/PageTrashControl.tsx new file mode 100644 index 00000000000..66a6a3dac5b --- /dev/null +++ b/templates/content/app/components/documents/PageTrashControl.tsx @@ -0,0 +1,239 @@ +import { actionErrorMessage } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { IconTrash } from "@tabler/icons-react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useRestoreDocument } from "@/hooks/use-documents"; +import { useTrashPages, type TrashPagesResult } from "@/hooks/use-trash-pages"; + +export interface TrashPageTarget { + id: string; + title: string; +} + +export function usePageTrashControl({ + pages, + onTrashed, +}: { + pages: TrashPageTarget[]; + onTrashed?: (result: TrashPagesResult) => void; +}) { + const t = useT(); + const trash = useTrashPages(); + const restore = useRestoreDocument(); + const [open, setOpen] = useState(false); + const [targets, setTargets] = useState([]); + const [error, setError] = useState(null); + const returnFocus = useRef(null); + + const request = (invoker?: HTMLElement) => { + const element = + invoker ?? + (document.activeElement instanceof HTMLElement + ? document.activeElement + : null); + const menu = element?.closest('[role="menu"]'); + returnFocus.current = menu + ? (Array.from( + document.querySelectorAll("[aria-controls]"), + ).find( + (trigger) => trigger.getAttribute("aria-controls") === menu.id, + ) ?? null) + : element; + setTargets([...new Map(pages.map((page) => [page.id, page])).values()]); + setError(null); + setOpen(true); + }; + + const confirm = async () => { + setError(null); + let result: TrashPagesResult; + try { + result = await trash.mutateAsync({ ids: targets.map((page) => page.id) }); + } catch (caught) { + setError(actionErrorMessage(caught) ?? t("pageTrash.failed")); + return; + } + + const roots = result.results.filter((item) => item.status === "trashed"); + const failures = result.results.filter((item) => item.status === "failed"); + if (roots.length > 0) { + toast( + t("pageTrash.trashed", { count: result.affectedDocumentIds.length }), + { + action: { + label: t("pageTrash.undo"), + onClick: () => { + void Promise.allSettled( + roots.map((item) => restore.mutateAsync({ id: item.id })), + ).then((outcomes) => { + const failed = outcomes.filter( + (outcome) => outcome.status === "rejected", + ); + if (failed.length > 0) + toast.error( + t("pageTrash.restoreFailed", { count: failed.length }), + ); + }); + }, + }, + }, + ); + } + if (failures.length > 0) { + const message = failures + .map((item) => { + const title = + targets.find((page) => page.id === item.id)?.title || + t("pageTrash.untitled"); + return `${title}: ${item.error?.message ?? t("pageTrash.failed")}`; + }) + .join("\n"); + setError(message); + setTargets( + targets.filter((page) => failures.some((item) => item.id === page.id)), + ); + toast.error(t("pageTrash.partialFailure", { count: failures.length }), { + description: message, + }); + } else { + setOpen(false); + } + onTrashed?.(result); + }; + + return { + open, + setOpen, + request, + confirm, + targets, + error, + pending: trash.isPending, + disabled: pages.length === 0, + onCloseAutoFocus: (event: Event) => { + if (returnFocus.current?.isConnected) { + event.preventDefault(); + returnFocus.current.focus(); + } + }, + }; +} + +export type PageTrashControl = ReturnType; + +export function PageTrashMenuItem({ control }: { control: PageTrashControl }) { + const t = useT(); + return ( + + control.request( + event.currentTarget instanceof HTMLElement + ? event.currentTarget + : undefined, + ) + } + > + + {t("pageTrash.actionSingle")} + + ); +} + +export function PageTrashSelectionButton({ + control, +}: { + control: PageTrashControl; +}) { + const t = useT(); + return ( + + ); +} + +export function PageTrashDialog({ control }: { control: PageTrashControl }) { + const t = useT(); + return ( + { + if (!control.pending) control.setOpen(open); + }} + > + + + + {t( + control.targets.length === 1 + ? "pageTrash.confirmSingle" + : "pageTrash.confirm", + )} + + +
    + {control.targets.map((page) => ( +
  • + {page.title || t("pageTrash.untitled")} +
  • + ))} +
+

{t("pageTrash.scope")}

+ {control.error && ( +

+ {control.error} +

+ )} + + + + +
+
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx b/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx index 422c65cbe17..2b6093f4539 100644 --- a/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { DatabaseSelectionBar, + databaseSelectionCanTrashPages, databaseSelectionCapabilities, } from "./DatabaseView"; @@ -79,7 +80,46 @@ describe("database selection permissions", () => { canDuplicateSelected: true, canRemoveSelected: true, }); - expect(managerMarkup).toContain(">Remove<"); + expect(managerMarkup).toContain("Remove from database"); + }); + + it("offers canonical trash in Files independently of membership removal", () => { + const page = item({ + document: { + id: "document-1", + canView: true, + canManage: true, + } as ContentDatabaseItem["document"], + }); + expect(databaseSelectionCanTrashPages([page], 1, false)).toBe(true); + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: "files", + selectedItemIds: [page.id], + selectedItems: [page], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }).canRemoveSelected, + ).toBe(false); + }); + + it("requires canonical management for every selected page and complete selection", () => { + const managed = item({ + document: { + id: "document-1", + canView: true, + canManage: true, + } as ContentDatabaseItem["document"], + }); + expect(databaseSelectionCanTrashPages([managed, item()], 2, false)).toBe( + false, + ); + expect(databaseSelectionCanTrashPages([managed], 2, false)).toBe(false); + expect(databaseSelectionCanTrashPages([managed], 1, true)).toBe(false); + expect(databaseSelectionCanTrashPages([], 0, false)).toBe(false); }); it("fails closed for stale and source-backed whole selections", () => { diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index ad73887ac64..919cfee7d35 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -218,6 +218,7 @@ vi.mock("@/hooks/use-documents", () => ({ }), seedDatabaseItemDocumentCaches: vi.fn(), useDeleteDocument: () => benignMutation, + useRestoreDocument: () => benignMutation, useUpdateDocument: () => benignMutation, })); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 1c1a0873cd4..a9ccd6fd64c 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -120,6 +120,13 @@ import { import { Link, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; +import { + PageTrashDialog, + PageTrashMenuItem, + PageTrashSelectionButton, + usePageTrashControl, + type PageTrashControl, +} from "@/components/documents/PageTrashControl"; import { SidebarTriggerContext } from "@/components/layout/sidebar-trigger"; import { QueryErrorState } from "@/components/QueryErrorState"; import { @@ -3845,6 +3852,19 @@ export function databaseSelectedItems( return visibleItems.filter((item) => selectedIds.has(item.id)); } +export function databaseSelectionCanTrashPages( + selectedItems: ContentDatabaseItem[], + selectedCount: number, + isWorkspaceCatalog: boolean, +) { + return ( + !isWorkspaceCatalog && + selectedCount > 0 && + selectedItems.length === selectedCount && + selectedItems.every((item) => item.document.canManage === true) + ); +} + export function databaseSelectionCapabilities(args: { canEdit: boolean; canManageDatabase: boolean; @@ -4671,6 +4691,7 @@ function DatabaseItemPreview({ const queryClient = useQueryClient(); const contentSpaces = useContentSpaces(); const deleteDocument = useDeleteDocument(); + const updatePreviewDocument = useUpdateDocument(); const deleteContentSpace = useDeleteContentSpace(); const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId); const { data: document } = useDocument(item.document.id, { @@ -4715,13 +4736,14 @@ function DatabaseItemPreview({ await sessionRef.current?.flush(); if (isWorkspaceCatalog && workspaceSpace?.kind === "user") { await deleteContentSpace.mutateAsync({ spaceId: workspaceSpace.id }); + } else if (removeFavorite) { + await updatePreviewDocument.mutateAsync({ + id: item.document.id, + isFavorite: false, + }); } else { await deleteDocument.mutateAsync({ id: item.document.id, - databaseDocumentId: - removesFavoriteMembership && !removeFavorite - ? undefined - : databaseDocumentId, }); } // The deleted Page's pending queue was settled before deletion. @@ -5072,6 +5094,19 @@ function DatabaseTableView({ contentSpaces.data?.favoritesDocumentId === databaseDocumentId; const isWorkspaceCatalog = contentSpaces.data?.catalogDocumentId === databaseDocumentId; + const canTrashSelected = databaseSelectionCanTrashPages( + selectedItems, + selectedCount, + isWorkspaceCatalog, + ); + const trashSelected = usePageTrashControl({ + pages: canTrashSelected ? selectedItems.map((item) => item.document) : [], + onTrashed: (result) => { + if (result.results.every((item) => item.status !== "failed")) { + onClearSelection(); + } + }, + }); const { canEditSelected, canDuplicateSelected, canRemoveSelected } = databaseSelectionCapabilities({ canEdit, @@ -5583,6 +5618,7 @@ function DatabaseTableView({ canEditSelected={canEditSelected} canDuplicateSelected={canDuplicateSelected} canRemoveSelected={canRemoveSelected} + trashControl={canTrashSelected ? trashSelected : undefined} properties={bulkEditableProperties} selectedItems={selectedItems} duplicateDisabled={ @@ -5608,6 +5644,7 @@ function DatabaseTableView({ }} /> ) : null} + {/* DataGrid preserves the table contract: data-database-scroll-surface="table", tabIndex={0}, and min-w-0 max-w-full overflow-x-auto. */} Promise; onDuplicateSelected: () => void; onRemoveSelected: () => void; + trashControl?: PageTrashControl; }) { return (
@@ -15424,9 +15463,14 @@ export function DatabaseSelectionBar({ ) : ( )} - Remove + {removesFavoriteMembership + ? sidebarText("removeFromFavorites") + : dbText("removeFromDatabase")} ) : null} + {trashControl ? ( + + ) : null}