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 (
+
+ );
+}
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}