diff --git a/.changeset/content-database-permission-removal.md b/.changeset/content-database-permission-removal.md new file mode 100644 index 0000000000..9762f658c6 --- /dev/null +++ b/.changeset/content-database-permission-removal.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Document the permission-aware Content database membership removal action. diff --git a/packages/core/docs/content/template-content-developers.mdx b/packages/core/docs/content/template-content-developers.mdx index 192d803b37..0ef31c4862 100644 --- a/packages/core/docs/content/template-content-developers.mdx +++ b/packages/core/docs/content/template-content-developers.mdx @@ -278,7 +278,7 @@ Every operation is a TypeScript file in `templates/content/actions/`, auto-mount **Comments** — `list-comments`, `add-comment`, `update-comment`, `delete-comment`. -**Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `delete-database-items`, `move-database-item`, `update-content-database-view`, `get-content-database-personal-view`, `update-content-database-personal-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, `delete-document-property`, `reorder-document-property`, `submit-content-database-form`. +**Databases, properties & views** — `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-content-databases`, `delete-content-database`, `restore-content-database`, `list-trashed-content-databases`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `get-content-database-personal-view`, `update-content-database-personal-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, `delete-document-property`, `reorder-document-property`, `submit-content-database-form`. **External database sources** — `attach-content-database-source`, `disconnect-content-database-source`, `get-content-database-source`, `refresh-content-database-source`, `review-content-database-source-change-set`, `bind-content-database-source-field`, `add-content-database-source-field-property`, `suggest-source-join-key`, `list-notion-database-sources`, `list-builder-cms-models`. Builder's write-back path adds `prepare-builder-source-review`, `preview-builder-source-review`, `prepare-builder-source-execution`, `validate-builder-source-execution`, `execute-builder-source-execution`, `execute-builder-source-batch`, `cancel-prepared-builder-source-update`, `stage-builder-revision`, `stage-builder-source-bulk-update`, `materialize-builder-required-fields`, and `process-builder-body-hydration`. diff --git a/plans/content-database-bulk-selection-toolbar-shape.md b/plans/content-database-bulk-selection-toolbar-shape.md new file mode 100644 index 0000000000..4a5d5f32d0 --- /dev/null +++ b/plans/content-database-bulk-selection-toolbar-shape.md @@ -0,0 +1,196 @@ +# Content database bulk-selection visibility and permission accuracy + +## Lifecycle authority + +```yaml +stage: work +authority-source: "Alice approved option 2 and shape-r4 on 2026-07-30, restoring the prior $work grant" +authorized-scope: + repositories: [BuilderIO/agent-native] + product-surfaces: [Agent Native Content database row actions] + outcome: Permission-accurate database bulk actions that remain visible in wide tables +allowed-mutations: [artifact-write, branch, commit, push, pull-request] +governing-artifact: + path: plans/content-database-bulk-selection-toolbar-shape.md + revision: shape-r4-approved +ledger-revision: content-bulk-selection-shape-r4 +status: active +``` + +`WORK RESUMED — SHAPE-R4 APPROVED` + +The former `ac1b` worktree and its untracked shaping file are no longer present. +This file reconstructs the approved `shape-r3` boundary from the task record and +records the new material delta found on refreshed `origin/main` before any code +edit. + +## Approved shape-r3 fingerprint + +- **Outcome:** keep the selected-row toolbar visible while the table scrolls; + show mutation controls only when the same viewer-scoped authorization used by + the Action permits the operation for the whole selection. +- **Shipping surface:** public Agent Native Content database table and row-action + surfaces for viewers and editors in Safari and supported desktop browsers. +- **Architecture:** fixed Database roles (**Can view / Can comment / Can edit + entries / Can edit database / Full access**) expand to internal capabilities; + object, row/Page, workspace, Property, and source restrictions may narrow but + never widen them. UI, Actions, agents, and APIs share one authorization + decision and fail closed on missing or stale capability data. +- **Acceptance:** viewers retain non-mutating selection count and Clear; entry + editors may edit/duplicate when permitted but cannot delete; Database editors + may delete only when effective authority covers the whole selection; stale + calls fail without mutation; Favorites Remove remains membership-only; + technical, real-Safari, and slow experience-audit evidence are required. +- **Risk:** system-ready, no feature flag, no production-validation-after-merge. + +## Material delta discovered during Work + +Refreshed `origin/main` at +`392440ec90d619df3b364cc3cb1cfa409aee1baa` contains an approved, July 29 +Database capability contract stating: + +> Removing a Page from a Database removes that membership and its +> membership-local values; it does not delete the Page or its other +> memberships. + +Current implementation does something materially different: + +- the bulk `delete-database-items` Action recursively moves every selected Page + and its descendants to Trash and removes all Database memberships; +- the single-row menu calls `delete-document`, labels the operation **Delete + row**, and warns that the Page and sub-pages will be permanently deleted; +- Favorites is the one existing special case that removes only membership; +- current sharing storage still exposes the generic internal roles + `viewer/editor/admin`; the approved Database role vocabulary is product shape, + not yet a complete implementation. + +The r3 phrase “delete a row” therefore hides two different user outcomes: + +1. remove this Page's membership in the current Database; or +2. delete the Page itself, including consequences outside this Database. + +Choosing between them changes the mutation, permission boundary, recovery +story, UI language, Action result, and multi-membership behavior. Work cannot +quietly choose. + +## Options + +### 1. Preserve recursive Page deletion and only repair affordance permissions + +Keep **Delete row**, require the current equivalent of **Can edit database** plus +Page-delete authority for every selected Page, and hide the action otherwise. + +- smallest code change; +- fixes Alice's immediate view-only affordance failure; +- conflicts with the approved Database contract and makes a Database operation + destroy material outside the current membership. + +### 2. Make ordinary row removal membership-only (recommended) + +Rename the operation **Remove from database** and remove only the selected +membership plus values owned by that Database. The Page, descendants, other +memberships, and unrelated values survive. Keep destructive Page deletion on +the Page's own action surface under Page-level authority. + +- matches the approved Database object and fixed-role contracts; +- makes **Can edit database** the authority for Database membership removal; +- keeps Page deletion a separate, honestly named consequence; +- requires the bulk and single-row Database surfaces to change together so the + product does not offer two meanings for “row deletion.” + +### 3. Offer both Remove from database and Delete page + +Expose two explicit actions with separate confirmations and capabilities. + +- preserves both jobs in context; +- adds consequential choice and UI density before evidence shows people need + destructive Page deletion from the Database surface; +- creates a larger first slice and a sharper accidental-deletion hazard. + +## Approved shape-r4 fingerprint + +- **Outcome:** selected-row controls remain visible and permission-accurate; + ordinary Database row removal removes membership without deleting the Page. +- **Shipping surface:** public Agent Native Content table bulk toolbar and + single-row action menu, plus the shared row-removal Action used by people and + agents. Favorites keeps its existing personal-membership behavior. +- **Architecture:** use the approved fixed Database role grammar. **Can edit + database** may remove memberships; **Can edit entries** may create/update but + not remove them; **Can view / Can comment** see no mutation controls. Effective + operation capabilities are viewer-scoped and narrowed by object, workspace, + source, and row/Page policies. Page deletion remains a distinct Page Action + under Page authority. No client-only permission system. +- **Acceptance:** use the replacement story below. +- **Risk:** system-ready, no feature flag, no production-validation-after-merge. + +## Replacement acceptance story + +Given a Database containing Pages that may also belong elsewhere: + +1. A **Can view** or **Can comment** collaborator can select rows for bounded + application/agent context and sees only selection count and Clear. No Edit, + Duplicate, Remove, row-create, or structural mutation control appears. +2. A **Can edit entries** collaborator sees only whole-selection operations they + can complete, including Edit and eligible Duplicate, but not **Remove from + database**. +3. A **Can edit database** collaborator with effective authority for every + selected membership sees **Remove from database**. Confirmation names the + membership consequence and does not claim that the Page will be deleted. +4. Removing one or many rows atomically deletes only the target + `content_database_items` memberships and values belonging to the target + Database, renumbers survivors, preserves each Page, descendants, other + memberships, unrelated Property values, access grants, and content, and + returns addressable removed-membership identifiers. +5. Mixed, missing, stale, source-read-only, or policy-restricted capability data + fails closed in the UI. A forced Action or agent call returns the same typed + denial before any write. +6. Destructive Page deletion remains available only through the separately + named Page action with Page-level authority and its own consequence language; + the Database toolbar does not smuggle that power into membership removal. +7. Favorites continues to remove personal membership without requiring + authority over the underlying Page. +8. The selection bar stays anchored while wide table content scrolls; narrow + widths, keyboard access, accessible names, cancellation, reload, and Safari + are covered. +9. Deterministic tests prove fixed-role mapping, membership/value scoping, + multi-membership survival, atomicity/idempotency, stale denial, no unintended + writes, and UI/Action/agent authorization parity. Independent technical + review covers the final authorization/destructive-behavior diff, followed by + independent real-interface QA. + +## Architecture grounding + +- **Demonstrated caller:** Alice's view-only Safari Database selection, where a + destructive control appeared and the Action later denied access. +- **Existing primitives:** shared `resolveAccess` / `assertAccess`, typed Actions, + `content_database_items` memberships, row/Page access metadata, application + selection state, and the current confirmation/dialog components. +- **Ownership boundary:** Database Actions own membership mutation; Page Actions + own Page deletion; the shared access layer owns effective authorization. +- **Legacy contracts:** Favorites remains membership-only; selection remains + non-mutating context; direct stale calls remain server-denied. +- **Smallest compatible delta:** one membership-removal Action contract consumed + by bulk and single-row Database surfaces, plus the already-shaped toolbar + placement fix. +- **Deferred:** custom roles, a client-side capability engine, bulk destructive + Page deletion from Database context, access-granting Views, and schema-wide + permission redesign. +- **Reversibility:** membership removal is narrower than Page deletion and does + not destroy the canonical Page; no feature flag is proposed because complete + system-ready evidence remains required. +- **Direct evidence:** + `templates/content/docs/product/capabilities/content.object.database.md`, + `templates/content/docs/product/capabilities/content.access.page-database.md`, + `templates/content/actions/delete-database-items.ts`, + `templates/content/actions/delete-document.ts`, and + `templates/content/app/components/editor/database/shared.tsx` on refreshed + `origin/main`. +- **Unresolved owner question:** whether ordinary Database “row deletion” means + membership removal or recursive Page deletion. The approved Database contract + says membership removal; Alice's decision binds this implementation slice. + +## Decision + +Alice approved option 2 and the replacement `shape-r4` story on July 30, 2026. +It preserves the Page and makes the Database boundary honest: removing a thing +from a view of work should not quietly erase the thing from the world. diff --git a/templates/content/.agents/skills/document-editing/references/databases.md b/templates/content/.agents/skills/document-editing/references/databases.md index 8065b82a33..7ab1701548 100644 --- a/templates/content/.agents/skills/document-editing/references/databases.md +++ b/templates/content/.agents/skills/document-editing/references/databases.md @@ -132,7 +132,7 @@ filters for that column, hide property columns in the current view without changing other views, and resize column widths. Column headers show compact sort/filter indicators when that column has active view constraints. Table rows can be selected with row checkboxes, and the table shows a compact -selected-row bar with clear, duplicate, confirmed delete, and bulk property +selected-row bar with clear, duplicate, confirmed membership removal, and bulk property set actions for editable non-computed fields. Empty table cells stay visually blank while remaining clickable for editing, and checkbox table cells render as compact checkbox glyphs instead of "Checked"/"Unchecked" @@ -228,16 +228,18 @@ property definition. Use `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-trashed-content-databases`, `restore-content-database`, `add-database-item`, `duplicate-database-item`, -`duplicate-database-items`, `delete-database-items`, `move-database-item`, +`duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, and `delete-document-property`; do not edit property rows or view config via raw SQL when an action can do it. When targeting more than one database row, call `duplicate-database-items` or -`delete-database-items` once with a native JSON array of `itemIds` or +`remove-database-items` once with a native JSON array of `itemIds` or `documentIds`. Do not loop `duplicate-database-item` or `delete-document` for -multi-row duplicate/delete requests. +multi-row duplicate or membership-removal requests. Removing a row from a +database preserves its Page, descendants, other database memberships, and +unrelated property values. Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, diff --git a/templates/content/actions/_database-membership-lock.ts b/templates/content/actions/_database-membership-lock.ts new file mode 100644 index 0000000000..c831d0c88f --- /dev/null +++ b/templates/content/actions/_database-membership-lock.ts @@ -0,0 +1,25 @@ +import { inArray, sql } from "drizzle-orm"; + +import { schema } from "../server/db/index.js"; +import { chunks } from "./_batch-utils.js"; + +export async function lockDatabaseMemberships(db: any, itemIds: string[]) { + const uniqueItemIds = [...new Set(itemIds)].sort(); + if (uniqueItemIds.length === 0) return; + const lockedIds = new Set(); + for (const itemIdChunk of chunks(uniqueItemIds, 90)) { + const lockedRows = await db + .update(schema.contentDatabaseItems) + .set({ + updatedAt: sql`${schema.contentDatabaseItems.updatedAt}`, + }) + .where(inArray(schema.contentDatabaseItems.id, itemIdChunk)) + .returning({ id: schema.contentDatabaseItems.id }); + for (const row of lockedRows) lockedIds.add(row.id); + } + if (lockedIds.size !== uniqueItemIds.length) { + throw new Error( + "Database memberships changed before the operation completed.", + ); + } +} diff --git a/templates/content/actions/_database-row-batch.ts b/templates/content/actions/_database-row-batch.ts index edd21b7392..9a4bf0bebf 100644 --- a/templates/content/actions/_database-row-batch.ts +++ b/templates/content/actions/_database-row-batch.ts @@ -79,6 +79,7 @@ function positionCaseSql( export async function resolveDatabaseRowsForBatch( input: DatabaseRowBatchInput, + options: { includeTrashed?: boolean } = {}, ): Promise<{ database: typeof schema.contentDatabases.$inferSelect; rows: DatabaseRowBatchRow[]; @@ -151,7 +152,7 @@ export async function resolveDatabaseRowsForBatch( eq(schema.contentDatabaseItems.databaseId, database.id), rowPredicates.length === 1 ? rowPredicates[0] : or(...rowPredicates), isNull(schema.contentDatabases.deletedAt), - isNull(schema.documents.trashedAt), + options.includeTrashed ? undefined : isNull(schema.documents.trashedAt), ), ) .orderBy(asc(schema.contentDatabaseItems.position)); @@ -177,7 +178,10 @@ export async function resolveDatabaseRowsForBatch( throw new Error("Duplicate database row references are not allowed."); } - return { database, rows: requestedRows }; + return { + database, + rows: requestedRows, + }; } export async function renumberDatabaseRows( diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 42ef43a29a..683927ee72 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -91,6 +91,7 @@ import { organizationContentSpaceId, provisionContentSpaces, } from "./_content-spaces.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { ensureFilesSystemPropertyDefinitions } from "./_files-system-properties.js"; import { databaseItemsPositionScope, @@ -1448,89 +1449,92 @@ async function enqueueBuilderBodyHydrations( const databaseItemIds = Array.from( new Set(uniqueRequests.map((request) => request.databaseItemId)), ); - const existingRows: ContentDatabaseBodyHydrationQueueRowDb[] = []; - for (const idChunk of chunks(databaseItemIds, idChunkSize())) { - existingRows.push( - ...(await db - .select() - .from(schema.contentDatabaseBodyHydrationQueue) - .where( - inArray( - schema.contentDatabaseBodyHydrationQueue.databaseItemId, - idChunk, - ), - )), + return db.transaction(async (tx) => { + await lockDatabaseMemberships(tx, databaseItemIds); + const existingRows: ContentDatabaseBodyHydrationQueueRowDb[] = []; + for (const idChunk of chunks(databaseItemIds, idChunkSize())) { + existingRows.push( + ...(await tx + .select() + .from(schema.contentDatabaseBodyHydrationQueue) + .where( + inArray( + schema.contentDatabaseBodyHydrationQueue.databaseItemId, + idChunk, + ), + )), + ); + } + const existingByItemId = new Map( + existingRows.map((row) => [row.databaseItemId, row]), ); - } - const existingByItemId = new Map( - existingRows.map((row) => [row.databaseItemId, row]), - ); - const queueRows: (typeof schema.contentDatabaseBodyHydrationQueue.$inferInsert)[] = - []; - for (const request of uniqueRequests) { - const existing = existingByItemId.get(request.databaseItemId); - const existingEntry = existing ? parseHydrationEntry(existing) : null; - const shouldPreserveExistingEntry = - builderEntryHasBodyContent(existingEntry) && - !builderEntryHasBodyContent(request.entry); - const priority = - request.priority ?? - builderBodyHydrationPriorityForRequest({ documentId: null }); - queueRows.push({ - id: existing?.id ?? crypto.randomUUID(), - ownerEmail: request.ownerEmail, - orgId: request.orgId, - sourceId: request.sourceId, - databaseItemId: request.databaseItemId, - documentId: request.documentId, - sourceRowId: request.entry.id, - sourceTable: request.sourceTable, - sourceEntryJson: shouldPreserveExistingEntry - ? existing!.sourceEntryJson - : JSON.stringify(request.entry), - priority: Math.min(existing?.priority ?? priority, priority), - attempts: existing?.attempts ?? 0, - lastAttemptedAt: existing?.lastAttemptedAt ?? null, - lastError: null, - createdAt: existing?.createdAt ?? request.now, - updatedAt: request.now, - }); - } - const upsertedRows: ContentDatabaseBodyHydrationQueueRowDb[] = []; - for (const chunk of chunks(queueRows, bulkChunkSizeForColumnCount(15))) { - upsertedRows.push( - ...(await db - .insert(schema.contentDatabaseBodyHydrationQueue) - .values(chunk) - .onConflictDoUpdate({ - target: schema.contentDatabaseBodyHydrationQueue.databaseItemId, - set: { - ownerEmail: sql`excluded.owner_email`, - orgId: sql`excluded.org_id`, - sourceId: sql`excluded.source_id`, - documentId: sql`excluded.document_id`, - sourceRowId: sql`excluded.source_row_id`, - sourceTable: sql`excluded.source_table`, - sourceEntryJson: sql`excluded.source_entry_json`, - priority: sql`excluded.priority`, - lastError: null, - updatedAt: sql`excluded.updated_at`, - }, + const queueRows: (typeof schema.contentDatabaseBodyHydrationQueue.$inferInsert)[] = + []; + for (const request of uniqueRequests) { + const existing = existingByItemId.get(request.databaseItemId); + const existingEntry = existing ? parseHydrationEntry(existing) : null; + const shouldPreserveExistingEntry = + builderEntryHasBodyContent(existingEntry) && + !builderEntryHasBodyContent(request.entry); + const priority = + request.priority ?? + builderBodyHydrationPriorityForRequest({ documentId: null }); + queueRows.push({ + id: existing?.id ?? crypto.randomUUID(), + ownerEmail: request.ownerEmail, + orgId: request.orgId, + sourceId: request.sourceId, + databaseItemId: request.databaseItemId, + documentId: request.documentId, + sourceRowId: request.entry.id, + sourceTable: request.sourceTable, + sourceEntryJson: shouldPreserveExistingEntry + ? existing!.sourceEntryJson + : JSON.stringify(request.entry), + priority: Math.min(existing?.priority ?? priority, priority), + attempts: existing?.attempts ?? 0, + lastAttemptedAt: existing?.lastAttemptedAt ?? null, + lastError: null, + createdAt: existing?.createdAt ?? request.now, + updatedAt: request.now, + }); + } + const upsertedRows: ContentDatabaseBodyHydrationQueueRowDb[] = []; + for (const chunk of chunks(queueRows, bulkChunkSizeForColumnCount(15))) { + upsertedRows.push( + ...(await tx + .insert(schema.contentDatabaseBodyHydrationQueue) + .values(chunk) + .onConflictDoUpdate({ + target: schema.contentDatabaseBodyHydrationQueue.databaseItemId, + set: { + ownerEmail: sql`excluded.owner_email`, + orgId: sql`excluded.org_id`, + sourceId: sql`excluded.source_id`, + documentId: sql`excluded.document_id`, + sourceRowId: sql`excluded.source_row_id`, + sourceTable: sql`excluded.source_table`, + sourceEntryJson: sql`excluded.source_entry_json`, + priority: sql`excluded.priority`, + lastError: null, + updatedAt: sql`excluded.updated_at`, + }, + }) + .returning()), + ); + } + for (const idChunk of chunks(databaseItemIds, idChunkSize())) { + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "pending", + bodyHydrationError: null, + updatedAt: uniqueRequests[0]!.now, }) - .returning()), - ); - } - for (const idChunk of chunks(databaseItemIds, idChunkSize())) { - await db - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "pending", - bodyHydrationError: null, - updatedAt: uniqueRequests[0]!.now, - }) - .where(inArray(schema.contentDatabaseItems.id, idChunk)); - } - return upsertedRows; + .where(inArray(schema.contentDatabaseItems.id, idChunk)); + } + return upsertedRows; + }); } export async function enqueueBuilderBodyHydrationForItems(args: { @@ -5308,7 +5312,27 @@ export async function seedMockSourceRows(args: { }) { if (args.items.length === 0) return; const db = getDb(); - const rows = args.items.map((item, index) => { + const rows = mockSourceRowsForSeed(args); + await db.transaction(async (tx) => { + await lockDatabaseMemberships( + tx, + rows.map((row) => row.databaseItemId), + ); + await insertMockSourceRows(tx, rows, args.now); + }); +} + +function mockSourceRowsForSeed(args: { + sourceId: string; + ownerEmail: string; + sourceType: ContentDatabaseSourceType; + sourceTable: string; + items: ContentDatabaseItem[]; + now: string; + existingBuilderRows?: Map; + builderEntriesByDocumentId?: Map; +}) { + return args.items.map((item, index) => { const builderEntry = args.builderEntriesByDocumentId?.get(item.document.id); const existingBuilderRow = args.existingBuilderRows?.get(item.document.id); const builderIdentity = @@ -5370,6 +5394,17 @@ export async function seedMockSourceRows(args: { updatedAt: args.now, }; }); +} + +async function insertMockSourceRows( + db: any, + rows: ReturnType, + now: string, +) { + if (rows.length === 0) return; + if (rows.some((row) => !row.sourceId)) { + throw new Error("Source row writes require a source ID."); + } await db .insert(schema.contentDatabaseSourceRows) .values(rows) @@ -5383,15 +5418,53 @@ export async function seedMockSourceRows(args: { .update(schema.contentDatabaseItems) .set({ bodyHydrationStatus: "unavailable", - bodyHydrationAttemptedAt: args.now, + bodyHydrationAttemptedAt: now, bodyHydrationError: null, bodyHydrationVersion: null, - updatedAt: args.now, + updatedAt: now, }) .where(inArray(schema.contentDatabaseItems.id, idChunk)); } } +export async function replaceMockSourceRows(args: { + sourceId: string; + ownerEmail: string; + sourceType: ContentDatabaseSourceType; + sourceTable: string; + items: ContentDatabaseItem[]; + now: string; + existingBuilderRows?: Map; + builderEntriesByDocumentId?: Map; + documentIds?: string[]; +}) { + const db = getDb(); + const rows = mockSourceRowsForSeed(args); + await db.transaction(async (tx) => { + const scope = args.documentIds?.length + ? and( + eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId), + inArray( + schema.contentDatabaseSourceRows.documentId, + args.documentIds, + ), + ) + : eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId); + const oldRows = await tx + .select({ + databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, + }) + .from(schema.contentDatabaseSourceRows) + .where(scope); + await lockDatabaseMemberships(tx, [ + ...oldRows.map((row) => row.databaseItemId).filter(Boolean), + ...rows.map((row) => row.databaseItemId), + ]); + await tx.delete(schema.contentDatabaseSourceRows).where(scope); + await insertMockSourceRows(tx, rows, args.now); + }); +} + export function sourceValuesForSeededSourceRow(args: { sourceType: ContentDatabaseSourceType; item: ContentDatabaseItem; @@ -5862,10 +5935,6 @@ export async function resyncMockSourceSnapshot(args: { .where( eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.source.id), ); - await db - .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id)); - await seedMockSourceFields({ sourceId: args.source.id, ownerEmail: args.database.ownerEmail, @@ -5873,7 +5942,7 @@ export async function resyncMockSourceSnapshot(args: { properties, now: args.now, }); - await seedMockSourceRows({ + await replaceMockSourceRows({ sourceId: args.source.id, ownerEmail: args.database.ownerEmail, sourceType: normalizeSourceType(args.source.sourceType), @@ -6054,6 +6123,7 @@ export function builderCmsEntryAlreadyRepresented(args: { export async function importBuilderCmsEntriesAsDatabaseItems(args: { database: ContentDatabaseRow; + sourceId: string; entries: BuilderCmsSourceEntry[]; now: string; sourceTable: string; @@ -6159,8 +6229,8 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { .filter((row) => !representedDocumentIds.has(row.document.id)) .map((row) => row.document.title.trim().toLowerCase()), ); - const currentItemByDocumentId = new Map( - currentItems.map((row) => [row.document.id, row.item]), + const currentRowByDocumentId = new Map( + currentItems.map((row) => [row.document.id, row]), ); // Reads MAX(position) for both `documents` and `content_database_items` @@ -6195,7 +6265,6 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { const documentRows: (typeof schema.documents.$inferInsert)[] = []; const itemRows: (typeof schema.contentDatabaseItems.$inferInsert)[] = []; - for (const entry of args.entries) { if ( builderCmsEntryAlreadyRepresented({ @@ -6213,9 +6282,9 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { sourceTable: args.sourceTable, entryId: entry.id, }); - const existingDeterministicItem = - currentItemByDocumentId.get(documentId); - if (existingDeterministicItem?.id === itemId) { + const existingDeterministicRow = + currentRowByDocumentId.get(documentId); + if (existingDeterministicRow?.item.id === itemId) { // A prior attach can commit the deterministic document/item and // fail before linking its source row. Treat that pair as the // same Builder identity so refresh repairs the missing link @@ -6230,7 +6299,8 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { continue; } - documentRows.push({ + const documentPosition = nextDocPosition++; + const documentRow = { id: documentId, spaceId: databaseSpaceId, ownerEmail: args.database.ownerEmail, @@ -6239,20 +6309,22 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { title, content: "", icon: null, - position: nextDocPosition++, + position: documentPosition, isFavorite: 0, hideFromSearch: databaseDocument?.hideFromSearch ?? 0, visibility: databaseDocument?.visibility ?? "private", createdAt: args.now, updatedAt: args.now, - }); + }; + const itemPosition = nextItemPosition++; + documentRows.push(documentRow); itemRows.push({ id: itemId, ownerEmail: args.database.ownerEmail, orgId: args.database.orgId, databaseId: args.database.id, documentId, - position: nextItemPosition++, + position: itemPosition, bodyHydrationStatus: "pending", bodyHydrationError: null, createdAt: args.now, @@ -6260,6 +6332,100 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { }); importedEntriesByDocumentId.set(documentId, entry); } + const candidateDocumentById = new Map( + documentRows.map((document) => [document.id!, document]), + ); + const sourceSeedItems: ContentDatabaseItem[] = [ + ...itemRows.map((item): ContentDatabaseItem => { + const document = candidateDocumentById.get(item.documentId)!; + return { + id: item.id!, + databaseId: args.database.id, + document: { + id: document.id!, + parentId: document.parentId ?? null, + title: document.title ?? "Untitled", + content: "", + icon: document.icon ?? null, + position: document.position ?? 0, + isFavorite: false, + hideFromSearch: Boolean(document.hideFromSearch), + visibility: document.visibility ?? "private", + accessRole: "owner" as const, + canView: true, + canEdit: true, + canManage: true, + createdAt: document.createdAt!, + updatedAt: document.updatedAt!, + }, + position: item.position ?? 0, + properties: [], + bodyHydration: { + status: "pending" as const, + attemptedAt: null, + error: null, + version: null, + }, + }; + }), + ...[...importedEntriesByDocumentId.keys()].flatMap( + (documentId): ContentDatabaseItem[] => { + if (candidateDocumentById.has(documentId)) return []; + const row = currentRowByDocumentId.get(documentId); + if (!row) return []; + return [ + { + id: row.item.id, + databaseId: args.database.id, + document: { + id: row.document.id, + parentId: row.document.parentId, + title: row.document.title, + content: "", + icon: row.document.icon, + position: row.document.position, + isFavorite: Boolean(row.document.isFavorite), + hideFromSearch: Boolean(row.document.hideFromSearch), + visibility: row.document.visibility, + accessRole: "owner" as const, + canView: true, + canEdit: true, + canManage: true, + createdAt: row.document.createdAt, + updatedAt: row.document.updatedAt, + }, + position: row.item.position, + properties: [], + bodyHydration: { + status: + row.item.bodyHydrationStatus === "pending" || + row.item.bodyHydrationStatus === "hydrating" || + row.item.bodyHydrationStatus === "hydrated" || + row.item.bodyHydrationStatus === "unavailable" || + row.item.bodyHydrationStatus === "error" + ? row.item.bodyHydrationStatus + : "hydrated", + attemptedAt: row.item.bodyHydrationAttemptedAt, + error: row.item.bodyHydrationError, + version: row.item.bodyHydrationVersion, + }, + }, + ]; + }, + ), + ]; + if (!args.sourceId) { + throw new Error("Builder imports require a source ID."); + } + const importedSourceRows = mockSourceRowsForSeed({ + sourceId: args.sourceId, + ownerEmail: args.database.ownerEmail, + sourceType: "builder-cms", + sourceTable: args.sourceTable, + items: sourceSeedItems, + now: args.now, + builderEntriesByDocumentId: importedEntriesByDocumentId, + }); const insertedItemIds = new Set(); await db.transaction(async (tx) => { for (const chunk of chunks( @@ -6284,6 +6450,11 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { insertedItemIds.add(item.id); } } + await lockDatabaseMemberships( + tx, + importedSourceRows.map((row) => row.databaseItemId), + ); + await insertMockSourceRows(tx, importedSourceRows, args.now); await ensureDocumentsFilesMembership( tx, documentRows.map((row) => row.id), @@ -6292,9 +6463,6 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { ); }); - const candidateDocumentById = new Map( - documentRows.map((document) => [document.id!, document]), - ); const importedItems: ContentDatabaseItem[] = itemRows .filter((item) => insertedItemIds.has(item.id!)) .map((item) => { @@ -6474,6 +6642,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { if (builderRead.state === "live") { const importResult = await importBuilderCmsEntriesAsDatabaseItems({ database: args.database, + sourceId: args.source.id, entries: builderEntries, now: args.now, sourceTable: args.source.sourceTable, @@ -6553,17 +6722,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { ); const fetchedDocumentIds = itemsToLink.map((item) => item.document.id); if (fetchedDocumentIds.length > 0) { - for (const idChunk of chunks(fetchedDocumentIds, idChunkSize())) { - await db - .delete(schema.contentDatabaseSourceRows) - .where( - and( - eq(schema.contentDatabaseSourceRows.sourceId, args.source.id), - inArray(schema.contentDatabaseSourceRows.documentId, idChunk), - ), - ); - } - await seedMockSourceRows({ + await replaceMockSourceRows({ sourceId: args.source.id, ownerEmail: args.database.ownerEmail, sourceType: "builder-cms", @@ -6572,6 +6731,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { now: args.now, existingBuilderRows, builderEntriesByDocumentId, + documentIds: fetchedDocumentIds, }); const refreshedFields = await db .select() @@ -6601,18 +6761,25 @@ export async function resyncBuilderCmsSourceSnapshot(args: { .select({ id: schema.contentDatabaseSourceRows.id, sourceRowId: schema.contentDatabaseSourceRows.sourceRowId, + databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, }) .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id)) ).filter((row) => !activeSourceRowIds.has(row.sourceRowId)); - for (const idChunk of chunks( - staleRows.map((row) => row.id), - idChunkSize(), - )) { - await db - .delete(schema.contentDatabaseSourceRows) - .where(inArray(schema.contentDatabaseSourceRows.id, idChunk)); - } + await db.transaction(async (tx) => { + await lockDatabaseMemberships( + tx, + staleRows.map((row) => row.databaseItemId).filter(Boolean), + ); + for (const idChunk of chunks( + staleRows.map((row) => row.id), + idChunkSize(), + )) { + await tx + .delete(schema.contentDatabaseSourceRows) + .where(inArray(schema.contentDatabaseSourceRows.id, idChunk)); + } + }); } await updateBuilderCmsSourceReadMetadata({ @@ -6644,10 +6811,6 @@ export async function resyncBuilderCmsSourceSnapshot(args: { .delete(schema.contentDatabaseSourceFields) .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id)); } - await db - .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id)); - if (shouldReseedSourceFields) { await seedMockSourceFields({ sourceId: args.source.id, @@ -6682,7 +6845,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { : existingBuilderRows.has(item.document.id), ) : response.items; - await seedMockSourceRows({ + await replaceMockSourceRows({ sourceId: args.source.id, ownerEmail: args.database.ownerEmail, sourceType: "builder-cms", @@ -6827,9 +6990,6 @@ export async function replaceSourceMetadata(args: { await db .delete(schema.contentDatabaseSourceFields) .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id)); - await db - .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id)); } if (args.source) { diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 11e938a9a4..9fd2c33308 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -191,11 +191,11 @@ async function contentDatabaseTableQueryMode( return usesSecondaryField ? "client-required" : "server"; } -function canManageRole(role: string) { +function canManageRole(role: string | undefined) { return role === "owner" || role === "admin"; } -function canEditRole(role: string) { +function canEditRole(role: string | undefined) { return role === "owner" || role === "admin" || role === "editor"; } @@ -399,7 +399,15 @@ function serializeDocument( const isOwner = doc.ownerEmail.trim().toLowerCase() === getRequestUserEmail()?.trim().toLowerCase(); - const accessRole = isOwner ? ("owner" as const) : (shareRole ?? "viewer"); + const hasVisibilityAccess = + doc.visibility === "public" || + (doc.visibility === "org" && + !!doc.orgId && + doc.orgId === getRequestOrgId()); + const canView = isOwner || hasVisibilityAccess || shareRole !== undefined; + const accessRole = isOwner + ? ("owner" as const) + : (shareRole ?? (hasVisibilityAccess ? ("viewer" as const) : undefined)); return { id: doc.id, parentId: doc.parentId, @@ -414,6 +422,7 @@ function serializeDocument( hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), visibility: doc.visibility, accessRole, + canView, canEdit: canEditRole(accessRole), canManage: canManageRole(accessRole), databaseMembership: membership diff --git a/templates/content/actions/add-content-database-source-field-property.test.ts b/templates/content/actions/add-content-database-source-field-property.test.ts index b2e20ecd1b..9398747aca 100644 --- a/templates/content/actions/add-content-database-source-field-property.test.ts +++ b/templates/content/actions/add-content-database-source-field-property.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; import { @@ -86,3 +88,22 @@ describe("propertyTypeForSourceField", () => { ).toEqual(["governance-security", "governance-security-2"]); }); }); + +describe("source-field materialization lock order", () => { + it("locks memberships before updating Builder source rows", () => { + const source = readFileSync( + new URL( + "./add-content-database-source-field-property.ts", + import.meta.url, + ), + "utf8", + ); + const membershipLock = source.indexOf("await lockDatabaseMemberships("); + const sourceRowUpdate = source.indexOf( + ".update(schema.contentDatabaseSourceRows)", + ); + + expect(membershipLock).toBeGreaterThan(-1); + expect(sourceRowUpdate).toBeGreaterThan(membershipLock); + }); +}); diff --git a/templates/content/actions/add-content-database-source-field-property.ts b/templates/content/actions/add-content-database-source-field-property.ts index 89edc80300..b0bcefc21f 100644 --- a/templates/content/actions/add-content-database-source-field-property.ts +++ b/templates/content/actions/add-content-database-source-field-property.ts @@ -28,6 +28,7 @@ import { builderCmsQualifiedId, type BuilderCmsSourceEntry, } from "./_builder-cms-source-adapter.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { resolveDatabaseForSourceMutation, serializeSourceField, @@ -460,6 +461,11 @@ export default defineAction({ documentId: string; sourceValuesJson: string | null; }> = []; + const sourceRowUpdates: Array<{ + id: string; + previousSourceValuesJson: string; + sourceValuesJson: string; + }> = []; if (builderEntries) { const currentSourceRows = await tx .select() @@ -487,27 +493,11 @@ export default defineAction({ ) { continue; } - const [updatedRow] = await tx - .update(schema.contentDatabaseSourceRows) - .set({ - sourceValuesJson: mergedRow.sourceValuesJson, - updatedAt: now, - }) - .where( - and( - eq(schema.contentDatabaseSourceRows.id, currentRow.id), - eq( - schema.contentDatabaseSourceRows.sourceValuesJson, - currentRow.sourceValuesJson, - ), - ), - ) - .returning({ id: schema.contentDatabaseSourceRows.id }); - if (!updatedRow) { - throw new Error( - "The Builder source changed while adding this property. No property was created; try again.", - ); - } + sourceRowUpdates.push({ + id: currentRow.id, + previousSourceValuesJson: currentRow.sourceValuesJson, + sourceValuesJson: mergedRow.sourceValuesJson, + }); } sourceRows = merged.rows; } else if (!isSecondary) { @@ -545,6 +535,34 @@ export default defineAction({ ); } + await lockDatabaseMemberships( + tx, + sourceRows.map((row) => row.databaseItemId), + ); + for (const update of sourceRowUpdates) { + const [updatedRow] = await tx + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: update.sourceValuesJson, + updatedAt: now, + }) + .where( + and( + eq(schema.contentDatabaseSourceRows.id, update.id), + eq( + schema.contentDatabaseSourceRows.sourceValuesJson, + update.previousSourceValuesJson, + ), + ), + ) + .returning({ id: schema.contentDatabaseSourceRows.id }); + if (!updatedRow) { + throw new Error( + "The Builder source changed while adding this property. No property was created; try again.", + ); + } + } + const builderMetadata = builderMetadataForSourceField({ sourceFieldKey: currentField.sourceFieldKey, sourceMetadataJson: currentSource.metadataJson, diff --git a/templates/content/actions/attach-content-database-source.ts b/templates/content/actions/attach-content-database-source.ts index 61316cce68..843b819583 100644 --- a/templates/content/actions/attach-content-database-source.ts +++ b/templates/content/actions/attach-content-database-source.ts @@ -27,6 +27,7 @@ import { importBuilderCmsEntriesAsDatabaseItems, insertSecondarySource, mapBuilderCmsEntriesToLocalItems, + replaceMockSourceRows, replaceSourceMetadata, resolveDatabaseForSourceMutation, seedMockSourceFields, @@ -511,6 +512,7 @@ export default defineAction({ if (additionalRead.state === "live") { const importResult = await importBuilderCmsEntriesAsDatabaseItems({ database, + sourceId: additionalSourceId, entries: additionalEntries, now, sourceTable, @@ -634,6 +636,7 @@ export default defineAction({ if (builderRead?.state === "live") { const importResult = await importBuilderCmsEntriesAsDatabaseItems({ database, + sourceId, entries: builderEntries, now, sourceTable, @@ -682,7 +685,7 @@ export default defineAction({ builderSampleEntries: builderEntries, now, }); - await seedMockSourceRows({ + await replaceMockSourceRows({ sourceId, ownerEmail: database.ownerEmail, sourceType, diff --git a/templates/content/actions/change-content-database-source-role.ts b/templates/content/actions/change-content-database-source-role.ts index 0507a711d3..eda799326c 100644 --- a/templates/content/actions/change-content-database-source-role.ts +++ b/templates/content/actions/change-content-database-source-role.ts @@ -23,9 +23,9 @@ import { importBuilderCmsEntriesAsDatabaseItems, mapBuilderCmsEntriesToLocalItems, mutateContentDatabaseSourceMetadata, + replaceMockSourceRows, resolveDatabaseForSourceMutation, seedMockSourceFields, - seedMockSourceRows, seedSecondarySourceFields, sourceSetupPayload, storeSecondarySourceRows, @@ -400,9 +400,6 @@ export default defineAction({ await db .delete(schema.contentDatabaseSourceFields) .where(eq(schema.contentDatabaseSourceFields.sourceId, source.id)); - await db - .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); await clearSourceFederation(source.id, now); let importedEntriesByDocumentId = new Map< @@ -412,6 +409,7 @@ export default defineAction({ if (read.state === "live") { const importResult = await importBuilderCmsEntriesAsDatabaseItems({ database, + sourceId: source.id, entries, now, sourceTable: source.sourceTable, @@ -443,7 +441,7 @@ export default defineAction({ builderSampleEntries: entries, now, }); - await seedMockSourceRows({ + await replaceMockSourceRows({ sourceId: source.id, ownerEmail: database.ownerEmail, sourceType: "builder-cms", diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 296c4e827d..4f1c5b958a 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -31,7 +31,7 @@ let updateDocumentAction: typeof import("./update-document.js").default; let setDocumentPropertyAction: typeof import("./set-document-property.js").default; let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; -let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let removeDatabaseItemsAction: typeof import("./remove-database-items.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; @@ -71,7 +71,7 @@ beforeAll(async () => { deleteContentDatabaseAction = (await import("./delete-content-database.js")) .default; deleteDocumentAction = (await import("./delete-document.js")).default; - deleteDatabaseItemsAction = (await import("./delete-database-items.js")) + removeDatabaseItemsAction = (await import("./remove-database-items.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) @@ -295,11 +295,13 @@ describe("Content space provisioning", () => { deleteDocumentAction.run({ id: first.catalogDocumentId }), ).rejects.toThrow("Workspace references cannot be deleted as pages"); await expect( - deleteDatabaseItemsAction.run({ + removeDatabaseItemsAction.run({ databaseId: first.catalogDatabaseId, itemIds: [first.catalogItemId], }), - ).rejects.toThrow("Workspace references cannot be deleted as pages"); + ).rejects.toThrow( + "Workspace references cannot be removed from a database as pages", + ); await expect( duplicateDatabaseItemAction.run({ itemId: first.catalogItemId }), ).rejects.toThrow("Workspace references cannot be duplicated as pages"); 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 c5f3491289..6c0a04dc56 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -1,4 +1,4 @@ -import { rmSync } from "node:fs"; +import { readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,8 @@ import { runWithRequestContext } from "@agent-native/core/server"; import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { ContentDatabaseItem } from "../shared/api.js"; + const TEST_DB_PATH = join( tmpdir(), `database-row-batch-actions-${process.pid}-${Date.now()}.sqlite`, @@ -16,9 +18,13 @@ let getDb: () => any; let schema: Schema; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; -let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let duplicateDocumentPropertyAction: typeof import("./duplicate-document-property.js").default; +let removeDatabaseItemsAction: typeof import("./remove-database-items.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; -let restoreDocumentAction: typeof import("./restore-document.js").default; +let lockDatabaseMemberships: typeof import("./_database-membership-lock.js").lockDatabaseMemberships; +let replaceMockSourceRows: typeof import("./_database-source-utils.js").replaceMockSourceRows; +let setDocumentPropertyAction: typeof import("./set-document-property.js").default; +let getContentDatabaseAction: typeof import("./get-content-database.js").default; let spaceId: string; const OWNER = "owner@example.com"; @@ -33,10 +39,19 @@ beforeAll(async () => { .default; duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) .default; - deleteDatabaseItemsAction = (await import("./delete-database-items.js")) + duplicateDocumentPropertyAction = ( + await import("./duplicate-document-property.js") + ).default; + removeDatabaseItemsAction = (await import("./remove-database-items.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; - restoreDocumentAction = (await import("./restore-document.js")).default; + ({ lockDatabaseMemberships } = + await import("./_database-membership-lock.js")); + ({ replaceMockSourceRows } = await import("./_database-source-utils.js")); + setDocumentPropertyAction = (await import("./set-document-property.js")) + .default; + getContentDatabaseAction = (await import("./get-content-database.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); const { systemIdsForContentSpace } = await import("./_content-spaces.js"); @@ -172,6 +187,76 @@ async function orderedRows(databaseId: string) { } describe("database row batch actions", () => { + it("reports truthful page-view capability for database rows", async () => { + const { databaseId, databaseDocumentId, rows } = + await createDatabaseWithRows(6); + const now = new Date().toISOString(); + await getDb() + .insert(schema.documentShares) + .values([ + { + id: nextId("share"), + resourceId: databaseDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "admin", + createdBy: OWNER, + createdAt: now, + }, + ...(["viewer", "editor", "admin"] as const).map((role, index) => ({ + id: nextId("share"), + resourceId: rows[index + 1].documentId, + principalType: "user" as const, + principalId: COLLABORATOR, + role, + createdBy: OWNER, + createdAt: now, + })), + ]); + await getDb() + .update(schema.documents) + .set({ visibility: "public" }) + .where(eq(schema.documents.id, rows[4].documentId)); + await getDb() + .update(schema.documents) + .set({ visibility: "org", orgId: "org-1" }) + .where(eq(schema.documents.id, rows[5].documentId)); + + const collaboratorResult = await runWithRequestContext( + { userEmail: COLLABORATOR, orgId: "org-1" }, + () => getContentDatabaseAction.run({ databaseId }), + ); + if (!("items" in collaboratorResult)) { + throw new Error("Expected a database response"); + } + expect( + collaboratorResult.items.map((item) => ({ + id: item.document.id, + role: item.document.accessRole, + canView: item.document.canView, + })), + ).toEqual([ + { id: rows[0].documentId, role: undefined, canView: false }, + { id: rows[1].documentId, role: "viewer", canView: true }, + { id: rows[2].documentId, role: "editor", canView: true }, + { id: rows[3].documentId, role: "admin", canView: true }, + { id: rows[4].documentId, role: "viewer", canView: true }, + { id: rows[5].documentId, role: "viewer", canView: true }, + ]); + + const ownerResult = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabaseAction.run({ databaseId }), + ); + if (!("items" in ownerResult)) throw new Error("Expected owner rows"); + expect( + ownerResult.items.every( + (item) => + item.document.accessRole === "owner" && + item.document.canView === true, + ), + ).toBe(true); + }); + it("duplicates selected rows as one ordered block with copied values and inherited shares", async () => { const db = getDb(); const { databaseId, databaseDocumentId, rows } = @@ -361,7 +446,7 @@ describe("database row batch actions", () => { ); }); - it("deletes selected rows recursively in one batch and renumbers survivors", async () => { + it("removes memberships and database-local values while preserving pages", async () => { const db = getDb(); const { databaseId, databaseDocumentId, rows } = await createDatabaseWithRows(4); @@ -371,11 +456,50 @@ describe("database row batch actions", () => { }); const now = new Date().toISOString(); const propertyId = nextId("property"); + const blocksPropertyId = nextId("blocks_property"); + await db.insert(schema.documentPropertyDefinitions).values([ + { + id: propertyId, + ownerEmail: OWNER, + databaseId, + name: "Notes", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: blocksPropertyId, + ownerEmail: OWNER, + databaseId, + name: "Research", + type: "blocks", + visibility: "always_show", + optionsJson: JSON.stringify({ blocksStorage: "field" }), + position: 1, + createdAt: now, + updatedAt: now, + }, + ]); + const other = await createDatabaseWithRows(0); + const otherItemId = nextId("other_item"); + const otherPropertyId = nextId("other_property"); + await db.insert(schema.contentDatabaseItems).values({ + id: otherItemId, + ownerEmail: OWNER, + databaseId: other.databaseId, + documentId: rows[1].documentId, + position: 0, + createdAt: now, + updatedAt: now, + }); await db.insert(schema.documentPropertyDefinitions).values({ - id: propertyId, + id: otherPropertyId, ownerEmail: OWNER, - databaseId, - name: "Notes", + databaseId: other.databaseId, + name: "Other notes", type: "text", visibility: "always_show", optionsJson: "{}", @@ -383,37 +507,58 @@ describe("database row batch actions", () => { createdAt: now, updatedAt: now, }); - await db.insert(schema.documentPropertyValues).values({ - id: nextId("value"), + await db.insert(schema.documentPropertyValues).values([ + { + id: nextId("value"), + ownerEmail: OWNER, + documentId: rows[1].documentId, + propertyId, + valueJson: JSON.stringify("Remove me"), + createdAt: now, + updatedAt: now, + }, + { + id: nextId("value"), + ownerEmail: OWNER, + documentId: rows[1].documentId, + propertyId: otherPropertyId, + valueJson: JSON.stringify("Preserve me"), + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.documentBlockFieldContents).values({ + id: nextId("blocks_value"), ownerEmail: OWNER, documentId: rows[1].documentId, - propertyId, - valueJson: JSON.stringify("Delete me"), + propertyId: blocksPropertyId, + content: "Remove this database-local Blocks value", createdAt: now, updatedAt: now, }); const result = await runWithRequestContext({ userEmail: OWNER }, () => - deleteDatabaseItemsAction.run({ + removeDatabaseItemsAction.run({ documentId: databaseDocumentId, itemIds: [rows[1].itemId, rows[2].itemId], }), ); - expect(result.deletedItemIds).toEqual([rows[1].itemId, rows[2].itemId]); - expect(result.deletedDocumentIds).toEqual([ + expect(result.removedItemIds).toEqual([rows[1].itemId, rows[2].itemId]); + expect(result.removedDocumentIds).toEqual([ rows[1].documentId, rows[2].documentId, ]); - expect(result.deletedCount).toBe(2); + expect(result.removedCount).toBe(2); const remainingRows = await orderedRows(databaseId); expect(remainingRows.map((row) => row.title)).toEqual(["Row 0", "Row 3"]); expect(remainingRows.map((row) => row.itemPosition)).toEqual([0, 1]); - const deletedDocs = await db + const preservedDocs = await db .select({ id: schema.documents.id, trashedAt: schema.documents.trashedAt, + content: schema.documents.content, }) .from(schema.documents) .where( @@ -423,19 +568,155 @@ describe("database row batch actions", () => { childDocumentId, ]), ); - expect(deletedDocs).toHaveLength(3); - expect(deletedDocs.every((document) => document.trashedAt)).toBe(true); + expect(preservedDocs).toHaveLength(3); + expect(preservedDocs.every((document) => document.trashedAt === null)).toBe( + true, + ); + expect( + preservedDocs.find((document) => document.id === rows[1].documentId) + ?.content, + ).toBe("Content 1"); + expect(await orderedRows(other.databaseId)).toEqual([ + expect.objectContaining({ + itemId: otherItemId, + documentId: rows[1].documentId, + }), + ]); const preservedValues = await db .select() .from(schema.documentPropertyValues) .where(eq(schema.documentPropertyValues.documentId, rows[1].documentId)); - expect(preservedValues).toHaveLength(1); + expect(preservedValues).toEqual([ + expect.objectContaining({ propertyId: otherPropertyId }), + ]); + await expect( + db + .select() + .from(schema.documentBlockFieldContents) + .where( + eq(schema.documentBlockFieldContents.documentId, rows[1].documentId), + ), + ).resolves.toEqual([]); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + documentIds: [rows[1].documentId, rows[2].documentId], + }), + ), + ).rejects.toThrow("All requested rows must exist in the target database"); + expect(await orderedRows(databaseId)).toHaveLength(2); + }); + + it("rejects removal from system databases whose memberships are canonical", async () => { + const [filesDatabase] = await getDb() + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.systemRole, "files")); + const created = await runWithRequestContext({ userEmail: OWNER }, () => + addDatabaseItemAction.run({ + databaseId: filesDatabase.id, + title: "Canonical file", + }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId: filesDatabase.id, + itemIds: [created.createdItemId], + }), + ), + ).rejects.toThrow( + "System database memberships cannot be removed from this surface.", + ); + expect(await orderedRows(filesDatabase.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ itemId: created.createdItemId }), + ]), + ); + }); + + it("keeps Favorites membership removal while preserving the Page and Files membership", async () => { + const db = getDb(); + const now = new Date().toISOString(); + const [filesDatabase] = await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.systemRole, "files")); + const favoritesDocumentId = await createDocument({ + title: "Favorites", + }); + const favoritesDatabaseId = nextId("favorites_db"); + await db.insert(schema.contentDatabases).values({ + id: favoritesDatabaseId, + spaceId, + systemRole: "favorites", + ownerEmail: OWNER, + documentId: favoritesDocumentId, + title: "Favorites", + createdAt: now, + updatedAt: now, + }); + const pageId = await createDocument({ + parentId: filesDatabase.documentId, + title: "Pinned page", + }); + const filesItemId = nextId("files_item"); + const favoriteItemId = nextId("favorite_item"); + await db.insert(schema.contentDatabaseItems).values([ + { + id: filesItemId, + ownerEmail: OWNER, + databaseId: filesDatabase.id, + documentId: pageId, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: favoriteItemId, + ownerEmail: OWNER, + databaseId: favoritesDatabaseId, + documentId: pageId, + position: 0, + createdAt: now, + updatedAt: now, + }, + ]); + + await runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId: favoritesDatabaseId, + itemIds: [favoriteItemId], + }), + ); + + expect(await orderedRows(favoritesDatabaseId)).toEqual([]); + expect(await orderedRows(filesDatabase.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ itemId: filesItemId, documentId: pageId }), + ]), + ); + expect( + await db + .select({ + id: schema.documents.id, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .where(eq(schema.documents.id, pageId)), + ).toEqual([{ id: pageId, trashedAt: null }]); }); - it("rejects duplicating trashed rows and restores unique ordering", async () => { + it("keeps removed pages out of duplicate actions", async () => { const { databaseId, rows } = await createDatabaseWithRows(2); await runWithRequestContext({ userEmail: OWNER }, () => - deleteDatabaseItemsAction.run({ + removeDatabaseItemsAction.run({ databaseId, itemIds: [rows[0].itemId], }), @@ -455,15 +736,14 @@ describe("database row batch actions", () => { ), ).rejects.toThrow("All requested rows must exist in the target database"); - await runWithRequestContext({ userEmail: OWNER }, () => - restoreDocumentAction.run({ id: rows[0].documentId }), - ); - const restoredRows = await orderedRows(databaseId); - expect(restoredRows.map((row) => row.itemPosition)).toEqual([0, 1]); - expect(restoredRows.map((row) => row.documentPosition)).toEqual([0, 1]); + const [page] = await getDb() + .select({ trashedAt: schema.documents.trashedAt }) + .from(schema.documents) + .where(eq(schema.documents.id, rows[0].documentId)); + expect(page.trashedAt).toBeNull(); }); - it("rejects unauthorized delete batches before writing", async () => { + it("rejects Can edit entries removal before writing", async () => { const { databaseId, databaseDocumentId, rows } = await createDatabaseWithRows(2); const db = getDb(); @@ -479,16 +759,425 @@ describe("database row batch actions", () => { await expect( runWithRequestContext({ userEmail: COLLABORATOR }, () => - deleteDatabaseItemsAction.run({ + removeDatabaseItemsAction.run({ databaseId, itemIds: [rows[0].itemId, rows[1].itemId], }), ), - ).rejects.toThrow(`No access to document ${rows[0].documentId}`); + ).rejects.toThrow( + `Requires admin role on document ${databaseDocumentId} (have editor)`, + ); expect(await orderedRows(databaseId)).toHaveLength(2); }); + it("rejects mixed target and foreign memberships before writing", async () => { + const target = await createDatabaseWithRows(2); + const foreign = await createDatabaseWithRows(1); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId: target.databaseId, + itemIds: [target.rows[0].itemId, foreign.rows[0].itemId], + }), + ), + ).rejects.toThrow("All requested rows must exist in the target database"); + + expect(await orderedRows(target.databaseId)).toHaveLength(2); + expect(await orderedRows(foreign.databaseId)).toHaveLength(1); + }); + + it("allows Can edit database with view-only access to the row pages", async () => { + const { databaseId, databaseDocumentId, rows } = + await createDatabaseWithRows(2); + const now = new Date().toISOString(); + await getDb() + .insert(schema.documentShares) + .values([ + { + id: nextId("share"), + resourceId: databaseDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "admin", + createdBy: OWNER, + createdAt: now, + }, + ...rows.map((row) => ({ + id: nextId("share"), + resourceId: row.documentId, + principalType: "user" as const, + principalId: COLLABORATOR, + role: "viewer" as const, + createdBy: OWNER, + createdAt: now, + })), + ]); + + const result = await runWithRequestContext( + { userEmail: COLLABORATOR }, + () => + removeDatabaseItemsAction.run({ + databaseId, + documentIds: [rows[0].documentId], + }), + ); + + expect(result.removedDocumentIds).toEqual([rows[0].documentId]); + expect(await orderedRows(databaseId)).toHaveLength(1); + const [page] = await getDb() + .select({ trashedAt: schema.documents.trashedAt }) + .from(schema.documents) + .where(eq(schema.documents.id, rows[0].documentId)); + expect(page.trashedAt).toBeNull(); + }); + + it("rejects source-backed row removal before writing", async () => { + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const sourceId = nextId("source"); + await getDb().insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "notion", + sourceName: "Read-only source", + sourceTable: "pages", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseSourceRows) + .values({ + id: nextId("source_row"), + ownerEmail: OWNER, + sourceId, + databaseItemId: rows[0].itemId, + documentId: rows[0].documentId, + sourceRowId: "source-row-1", + sourceQualifiedId: "notion:source-row-1", + sourceDisplayKey: "Source row", + createdAt: now, + updatedAt: now, + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ).rejects.toThrow("Source-backed rows cannot be removed"); + expect(await orderedRows(databaseId)).toHaveLength(1); + + await getDb() + .delete(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.databaseItemId, rows[0].itemId), + ); + await getDb() + .insert(schema.contentDatabaseBodyHydrationQueue) + .values({ + id: nextId("hydration"), + ownerEmail: OWNER, + sourceId, + databaseItemId: rows[0].itemId, + documentId: rows[0].documentId, + sourceRowId: "source-row-1", + sourceTable: "pages", + createdAt: now, + updatedAt: now, + }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ).rejects.toThrow("Source-backed rows cannot be removed"); + expect(await orderedRows(databaseId)).toHaveLength(1); + }); + + it("serializes a racing source association before membership removal", async () => { + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const sourceId = nextId("source"); + await getDb().insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "notion", + sourceName: "Racing source", + sourceTable: "pages", + createdAt: now, + updatedAt: now, + }); + + let releaseWriter!: () => void; + const writerCanFinish = new Promise((resolve) => { + releaseWriter = resolve; + }); + let writerLocked!: () => void; + const writerHasLock = new Promise((resolve) => { + writerLocked = resolve; + }); + const writer = getDb().transaction(async (tx: any) => { + await lockDatabaseMemberships(tx, [rows[0].itemId]); + writerLocked(); + await writerCanFinish; + await tx.insert(schema.contentDatabaseSourceRows).values({ + id: nextId("source_row"), + ownerEmail: OWNER, + sourceId, + databaseItemId: rows[0].itemId, + documentId: rows[0].documentId, + sourceRowId: "racing-row", + sourceQualifiedId: "notion:racing-row", + sourceDisplayKey: "Racing row", + createdAt: now, + updatedAt: now, + }); + }); + await writerHasLock; + + const removal = runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ); + releaseWriter(); + await writer; + + await expect(removal).rejects.toThrow( + "Source-backed rows cannot be removed", + ); + expect(await orderedRows(databaseId)).toHaveLength(1); + }); + + it("rolls back a source-row replacement when any target membership is stale", async () => { + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const sourceId = nextId("source"); + const oldSourceRowId = nextId("source_row"); + await getDb().insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "mock-local", + sourceName: "Atomic source", + sourceTable: "rows", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseSourceRows).values({ + id: oldSourceRowId, + ownerEmail: OWNER, + sourceId, + databaseItemId: rows[0].itemId, + documentId: rows[0].documentId, + sourceRowId: "old-row", + sourceQualifiedId: "mock-local://rows/old-row", + sourceDisplayKey: "Old row", + createdAt: now, + updatedAt: now, + }); + + const replacementItems = [ + { + id: rows[0].itemId, + databaseId, + document: { id: rows[0].documentId, title: "Existing" }, + position: 0, + properties: [], + }, + { + id: nextId("missing_item"), + databaseId, + document: { id: nextId("missing_document"), title: "Missing" }, + position: 1, + properties: [], + }, + ] as ContentDatabaseItem[]; + + await expect( + replaceMockSourceRows({ + sourceId, + ownerEmail: OWNER, + sourceType: "mock-local", + sourceTable: "rows", + items: replacementItems, + now, + }), + ).rejects.toThrow("Database memberships changed"); + + const sourceRows = await getDb() + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + expect(sourceRows).toEqual([{ id: oldSourceRowId }]); + }); + + it("leaves no database-local value after property set races membership removal", async () => { + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const propertyId = nextId("property"); + await getDb().insert(schema.documentPropertyDefinitions).values({ + id: propertyId, + ownerEmail: OWNER, + databaseId, + name: "Status", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 0, + createdAt: now, + updatedAt: now, + }); + + const [propertyResult, removalResult] = await Promise.allSettled([ + runWithRequestContext({ userEmail: OWNER }, () => + setDocumentPropertyAction.run({ + documentId: rows[0].documentId, + databaseId, + propertyId, + value: "Ready", + }), + ), + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ]); + + expect(removalResult.status).toBe("fulfilled"); + expect(["fulfilled", "rejected"]).toContain(propertyResult.status); + expect( + await getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.id, rows[0].itemId)), + ).toEqual([]); + expect( + await getDb() + .select({ id: schema.documentPropertyValues.id }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, rows[0].documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ), + ).toEqual([]); + }); + + it("leaves no copied value when property duplication races membership removal", async () => { + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const propertyId = nextId("property"); + await getDb().insert(schema.documentPropertyDefinitions).values({ + id: propertyId, + ownerEmail: OWNER, + databaseId, + name: "Status", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 0, + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.documentPropertyValues) + .values({ + id: nextId("value"), + ownerEmail: OWNER, + documentId: rows[0].documentId, + propertyId, + valueJson: JSON.stringify("Ready"), + createdAt: now, + updatedAt: now, + }); + + const [duplicateResult, removalResult] = await Promise.allSettled([ + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDocumentPropertyAction.run({ + documentId: rows[0].documentId, + databaseId, + propertyId, + }), + ), + runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ]); + + expect(removalResult.status).toBe("fulfilled"); + expect(["fulfilled", "rejected"]).toContain(duplicateResult.status); + + expect( + await getDb() + .select({ id: schema.documentPropertyValues.id }) + .from(schema.documentPropertyValues) + .innerJoin( + schema.documentPropertyDefinitions, + eq( + schema.documentPropertyDefinitions.id, + schema.documentPropertyValues.propertyId, + ), + ) + .where( + and( + eq(schema.documentPropertyValues.documentId, rows[0].documentId), + eq(schema.documentPropertyDefinitions.databaseId, databaseId), + ), + ), + ).toEqual([]); + }); + + it("keeps copied property values inside the membership-locked transaction", () => { + const source = readFileSync( + new URL("./duplicate-document-property.ts", import.meta.url), + "utf8", + ); + const transactionStart = source.indexOf( + "await db.transaction(async (tx) => {", + ); + const transactionEnd = source.indexOf( + "\n });\n },\n );", + transactionStart, + ); + expect(transactionStart).toBeGreaterThan(-1); + expect(transactionEnd).toBeGreaterThan(transactionStart); + + const transactionBody = source.slice(transactionStart, transactionEnd); + const membershipLock = transactionBody.indexOf( + "await lockDatabaseMemberships(", + ); + const definitionInsert = transactionBody.indexOf( + "await tx.insert(schema.documentPropertyDefinitions)", + ); + const valueRead = transactionBody.indexOf( + ".from(schema.documentPropertyValues)", + ); + const valueInsert = transactionBody.indexOf( + "await tx.insert(schema.documentPropertyValues)", + ); + + expect(membershipLock).toBeGreaterThan(-1); + expect(definitionInsert).toBeGreaterThan(membershipLock); + expect(valueRead).toBeGreaterThan(definitionInsert); + expect(valueInsert).toBeGreaterThan(valueRead); + }); + it("rejects oversized batches before mutation", async () => { const { databaseId, rows } = await createDatabaseWithRows(1); diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts deleted file mode 100644 index f8e65f45dd..0000000000 --- a/templates/content/actions/delete-database-items.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { defineAction } from "@agent-native/core"; -import { writeAppState } from "@agent-native/core/application-state"; -import { assertAccess } from "@agent-native/core/sharing"; -import { inArray } from "drizzle-orm"; - -import { getDb, schema } from "../server/db/index.js"; -import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; -import { - databaseRowBatchSchema, - renumberDatabaseRows, - resolveDatabaseRowsForBatch, -} from "./_database-row-batch.js"; -import { getContentDatabaseResponse } from "./_database-utils.js"; -import { trashDocumentSubtree } from "./delete-document.js"; - -export default defineAction({ - description: - "Delete multiple page rows from a content database in one atomic batch. Use this for two or more selected/named rows instead of looping delete-document.", - schema: databaseRowBatchSchema, - run: async (args) => { - const db = getDb(); - const { database, rows } = await resolveDatabaseRowsForBatch(args); - - await assertAccess("document", database.documentId, "editor"); - if (database.systemRole === "favorites") { - const removedItemIds = rows.map((row) => row.item.id); - const removedDocumentIds = rows.map((row) => row.document.id); - const now = new Date().toISOString(); - await db.transaction(async (tx) => { - await tx - .delete(schema.contentDatabaseItems) - .where(inArray(schema.contentDatabaseItems.id, removedItemIds)); - await renumberDatabaseRows( - tx as unknown as ReturnType, - database, - now, - ); - }); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - ...(await getContentDatabaseResponse(database.id, { - limit: 100, - offset: 0, - })), - deletedItemIds: removedItemIds, - deletedDocumentIds: [], - deletedCount: 0, - removedDocumentIds, - removedCount: removedItemIds.length, - }; - } - for (const row of rows) { - await assertAccess("document", row.document.id, "admin"); - } - - const deletedItemIds = rows.map((row) => row.item.id); - const deletedDocumentIds = rows.map((row) => row.document.id); - await assertNotWorkspaceCatalogDocuments(db, deletedDocumentIds, "deleted"); - const now = new Date().toISOString(); - - await db.transaction(async (tx) => { - for (const row of rows) { - await trashDocumentSubtree( - tx as unknown as ReturnType, - row.document.id, - row.document.ownerEmail, - now, - ); - } - await renumberDatabaseRows( - tx as unknown as ReturnType, - database, - now, - ); - }); - - await writeAppState("refresh-signal", { ts: Date.now() }); - - return { - ...(await getContentDatabaseResponse(database.id, { - limit: 100, - offset: 0, - })), - deletedItemIds, - deletedDocumentIds, - deletedCount: deletedItemIds.length, - }; - }, -}); diff --git a/templates/content/actions/duplicate-document-property.ts b/templates/content/actions/duplicate-document-property.ts index 053fdf033a..864712e59f 100644 --- a/templates/content/actions/duplicate-document-property.ts +++ b/templates/content/actions/duplicate-document-property.ts @@ -10,6 +10,7 @@ import { serializePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { propertyDefinitionsPositionScope, withPositionLock, @@ -76,57 +77,69 @@ export default defineAction({ await withPositionLock( propertyDefinitionsPositionScope(database.id), async () => { - const [maxPos] = await db - .select({ - max: sql`COALESCE(MAX(position), -1)`, - }) - .from(schema.documentPropertyDefinitions) - .where( - and( - eq( - schema.documentPropertyDefinitions.ownerEmail, - document.ownerEmail, - ), - eq(schema.documentPropertyDefinitions.databaseId, database.id), - ), + await db.transaction(async (tx) => { + const memberships = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, database.id)); + await lockDatabaseMemberships( + tx, + memberships.map((membership) => membership.id), ); - await db.insert(schema.documentPropertyDefinitions).values({ - id: newPropertyId, - ownerEmail: definition.ownerEmail, - orgId: definition.orgId, - databaseId: database.id, - name: `${definition.name} copy`, - type: definition.type, - visibility: definition.visibility, - optionsJson, - position: (maxPos?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - }, - ); + const [maxPos] = await tx + .select({ + max: sql`COALESCE(MAX(position), -1)`, + }) + .from(schema.documentPropertyDefinitions) + .where( + and( + eq( + schema.documentPropertyDefinitions.ownerEmail, + document.ownerEmail, + ), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ); - // Blocks fields don't use document_property_values; a duplicate starts empty. - if (!isBlocks) { - const values = await db - .select() - .from(schema.documentPropertyValues) - .where(eq(schema.documentPropertyValues.propertyId, propertyId)); - if (values.length > 0) { - await db.insert(schema.documentPropertyValues).values( - values.map((value) => ({ - id: nanoid(), - ownerEmail: value.ownerEmail, - documentId: value.documentId, - propertyId: newPropertyId, - valueJson: value.valueJson, + await tx.insert(schema.documentPropertyDefinitions).values({ + id: newPropertyId, + ownerEmail: definition.ownerEmail, + orgId: definition.orgId, + databaseId: database.id, + name: `${definition.name} copy`, + type: definition.type, + visibility: definition.visibility, + optionsJson, + position: (maxPos?.max ?? -1) + 1, createdAt: now, updatedAt: now, - })), - ); - } - } + }); + + // Blocks fields don't use document_property_values; a duplicate + // starts empty. + if (!isBlocks) { + const values = await tx + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.propertyId, propertyId)); + if (values.length > 0) { + await tx.insert(schema.documentPropertyValues).values( + values.map((value) => ({ + id: nanoid(), + ownerEmail: value.ownerEmail, + documentId: value.documentId, + propertyId: newPropertyId, + valueJson: value.valueJson, + createdAt: now, + updatedAt: now, + })), + ); + } + } + }); + }, + ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index f90d57b7fd..bed7317cdb 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -44,6 +44,7 @@ import { executeBuilderCmsWrite, } from "./_builder-cms-write-client.js"; import { createBuilderSourceTiming } from "./_builder-source-timings.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, @@ -563,80 +564,85 @@ async function reconcileBuilderCmsWrite(args: { } const db = getDb(); - const existingRow = - args.changeSet.documentId || args.changeSet.databaseItemId - ? await db - .select() - .from(schema.contentDatabaseSourceRows) - .where( - and( - eq(schema.contentDatabaseSourceRows.sourceId, args.source.id), - args.changeSet.documentId - ? eq( - schema.contentDatabaseSourceRows.documentId, - args.changeSet.documentId, - ) - : eq( - schema.contentDatabaseSourceRows.databaseItemId, - args.changeSet.databaseItemId as string, - ), - ), - ) - .limit(1) - : []; + await db.transaction(async (tx) => { + if (args.changeSet.databaseItemId) { + await lockDatabaseMemberships(tx, [args.changeSet.databaseItemId]); + } + const existingRow = + args.changeSet.documentId || args.changeSet.databaseItemId + ? await tx + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, args.source.id), + args.changeSet.documentId + ? eq( + schema.contentDatabaseSourceRows.documentId, + args.changeSet.documentId, + ) + : eq( + schema.contentDatabaseSourceRows.databaseItemId, + args.changeSet.databaseItemId as string, + ), + ), + ) + .limit(1) + : []; - const [row] = existingRow; - const snapshotRow = sourceRowForChangeSet(args.source, args.changeSet); - const sourceValuesJson = builderCmsReconciledSourceValuesJson({ - existingSourceValuesJson: row?.sourceValuesJson, - snapshotSourceValues: snapshotRow?.sourceValues, - changeSet: args.changeSet, - plan: args.plan, - }); - const patchWithValues = { - ...patch, - sourceValuesJson, - }; - if (row) { - await db - .update(schema.contentDatabaseSourceRows) - .set(patchWithValues) - .where(eq(schema.contentDatabaseSourceRows.id, row.id)); - } else if (args.changeSet.documentId && args.changeSet.databaseItemId) { - await db.insert(schema.contentDatabaseSourceRows).values({ - id: crypto.randomUUID(), - ownerEmail: args.database.ownerEmail, - sourceId: args.source.id, - databaseItemId: args.changeSet.databaseItemId, - documentId: args.changeSet.documentId, - createdAt: args.now, - ...patchWithValues, + const [row] = existingRow; + const snapshotRow = sourceRowForChangeSet(args.source, args.changeSet); + const sourceValuesJson = builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: row?.sourceValuesJson, + snapshotSourceValues: snapshotRow?.sourceValues, + changeSet: args.changeSet, + plan: args.plan, }); - } else { - throw new Error( - "Builder write succeeded, but the local source row was missing.", - ); - } + const patchWithValues = { + ...patch, + sourceValuesJson, + }; + if (row) { + await tx + .update(schema.contentDatabaseSourceRows) + .set(patchWithValues) + .where(eq(schema.contentDatabaseSourceRows.id, row.id)); + } else if (args.changeSet.documentId && args.changeSet.databaseItemId) { + await tx.insert(schema.contentDatabaseSourceRows).values({ + id: crypto.randomUUID(), + ownerEmail: args.database.ownerEmail, + sourceId: args.source.id, + databaseItemId: args.changeSet.databaseItemId, + documentId: args.changeSet.documentId, + createdAt: args.now, + ...patchWithValues, + }); + } else { + throw new Error( + "Builder write succeeded, but the local source row was missing.", + ); + } - await db - .update(schema.contentDatabaseSourceFields) - .set({ - freshness: "fresh", - lastSyncedAt: args.now, - updatedAt: args.now, - }) - .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id)); - await db - .update(schema.contentDatabaseSources) - .set({ - syncState: "idle", - freshness: "fresh", - lastRefreshedAt: args.now, - lastSourceUpdatedAt: patch.lastSourceUpdatedAt, - lastError: null, - updatedAt: args.now, - }) - .where(eq(schema.contentDatabaseSources.id, args.source.id)); + await tx + .update(schema.contentDatabaseSourceFields) + .set({ + freshness: "fresh", + lastSyncedAt: args.now, + updatedAt: args.now, + }) + .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id)); + await tx + .update(schema.contentDatabaseSources) + .set({ + syncState: "idle", + freshness: "fresh", + lastRefreshedAt: args.now, + lastSourceUpdatedAt: patch.lastSourceUpdatedAt, + lastError: null, + updatedAt: args.now, + }) + .where(eq(schema.contentDatabaseSources.id, args.source.id)); + }); } export function realExecutionDeps( diff --git a/templates/content/actions/materialize-builder-required-fields.ts b/templates/content/actions/materialize-builder-required-fields.ts index 229f075cac..c5d1b70808 100644 --- a/templates/content/actions/materialize-builder-required-fields.ts +++ b/templates/content/actions/materialize-builder-required-fields.ts @@ -19,6 +19,7 @@ import { } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; import { readBuilderCmsContentEntries } from "./_builder-cms-read-client.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { builderReferenceIdSourceValueKey, resolveDatabaseForSourceMutation, @@ -352,6 +353,12 @@ export default defineAction({ .select() .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + await lockDatabaseMemberships( + tx, + currentSourceRows + .map((row) => row.databaseItemId) + .filter((itemId) => itemId.length > 0), + ); const [maxPosition] = await tx .select({ max: sql`COALESCE(MAX(position), -1)` }) .from(schema.documentPropertyDefinitions) diff --git a/templates/content/actions/remove-database-items.ts b/templates/content/actions/remove-database-items.ts new file mode 100644 index 0000000000..2b991f5ba8 --- /dev/null +++ b/templates/content/actions/remove-database-items.ts @@ -0,0 +1,183 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, inArray } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; +import { + databaseRowBatchSchema, + renumberDatabaseRows, + resolveDatabaseRowsForBatch, +} from "./_database-row-batch.js"; +import { getContentDatabaseResponse } from "./_database-utils.js"; +import { + databaseItemsPositionScope, + withPositionLock, +} from "./_position-utils.js"; + +export default defineAction({ + description: + "Remove one or more page memberships from a content database in one atomic batch without deleting the pages. Use this once for two or more selected or named rows instead of looping page operations.", + schema: databaseRowBatchSchema, + run: async (args) => { + const db = getDb(); + const { database, rows } = await resolveDatabaseRowsForBatch(args, { + includeTrashed: true, + }); + + if (database.systemRole === "favorites") { + await assertAccess("document", database.documentId, "editor"); + const removedItemIds = rows.map((row) => row.item.id); + const removedDocumentIds = rows.map((row) => row.document.id); + const now = new Date().toISOString(); + await withPositionLock(databaseItemsPositionScope(database.id), () => + db.transaction(async (tx) => { + if (removedItemIds.length > 0) { + await tx + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.id, removedItemIds)); + } + await renumberDatabaseRows( + tx as unknown as ReturnType, + database, + now, + ); + }), + ); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + ...(await getContentDatabaseResponse(database.id, { + limit: 100, + offset: 0, + })), + removedItemIds, + removedDocumentIds, + removedCount: removedItemIds.length, + }; + } + + await assertAccess("document", database.documentId, "admin"); + for (const row of rows) { + await assertAccess("document", row.document.id, "viewer"); + } + + const removedItemIds = rows.map((row) => row.item.id); + const removedDocumentIds = rows.map((row) => row.document.id); + await assertNotWorkspaceCatalogDocuments( + db, + removedDocumentIds, + "removed from a database", + ); + if (database.systemRole) { + throw new Error( + "System database memberships cannot be removed from this surface.", + ); + } + + const now = new Date().toISOString(); + + await withPositionLock(databaseItemsPositionScope(database.id), () => + db.transaction(async (tx) => { + if (removedItemIds.length > 0) { + await lockDatabaseMemberships(tx, removedItemIds); + const [sourceRow, hydrationRow] = await Promise.all([ + tx + .select({ id: schema.contentDatabaseSourceRows.id }) + .from(schema.contentDatabaseSourceRows) + .where( + inArray( + schema.contentDatabaseSourceRows.databaseItemId, + removedItemIds, + ), + ) + .limit(1), + tx + .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) + .from(schema.contentDatabaseBodyHydrationQueue) + .where( + inArray( + schema.contentDatabaseBodyHydrationQueue.databaseItemId, + removedItemIds, + ), + ) + .limit(1), + ]); + if (sourceRow.length > 0 || hydrationRow.length > 0) { + throw new Error( + "Source-backed rows cannot be removed from this database.", + ); + } + } + const propertyIds = ( + await tx + .select({ id: schema.documentPropertyDefinitions.id }) + .from(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ) + ).map((property) => property.id); + if (removedDocumentIds.length > 0 && propertyIds.length > 0) { + await tx + .delete(schema.documentPropertyValues) + .where( + and( + inArray( + schema.documentPropertyValues.documentId, + removedDocumentIds, + ), + inArray(schema.documentPropertyValues.propertyId, propertyIds), + ), + ); + await tx + .delete(schema.documentBlockFieldContents) + .where( + and( + inArray( + schema.documentBlockFieldContents.documentId, + removedDocumentIds, + ), + inArray( + schema.documentBlockFieldContents.propertyId, + propertyIds, + ), + ), + ); + } + if (removedItemIds.length > 0) { + await tx + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.id, removedItemIds)); + } + await renumberDatabaseRows( + tx as unknown as ReturnType, + database, + now, + ); + + if (removedItemIds.length > 0) { + const remaining = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.id, removedItemIds)); + if (remaining.length > 0) { + throw new Error("Database memberships were not fully removed."); + } + } + }), + ); + + await writeAppState("refresh-signal", { ts: Date.now() }); + + return { + ...(await getContentDatabaseResponse(database.id, { + limit: 100, + offset: 0, + })), + removedItemIds, + removedDocumentIds, + removedCount: removedItemIds.length, + }; + }, +}); 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 a23a29b34b..b443681946 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -1922,6 +1922,7 @@ it("records freshly imported Builder row identities even when title and URL keys const entries = read.state === "live" ? read.entries : []; const importResult = await importBuilderEntries({ database, + sourceId: "src-duplicates", entries, now, sourceTable: "collection-duplicates", @@ -1933,6 +1934,14 @@ it("records freshly imported Builder row identities even when title and URL keys expect(importResult.imported).toBe(2); expect(importedIds.sort()).toEqual(["entry-dup-1", "entry-dup-2"]); + const importedSourceRows = await db + .select({ sourceRowId: schema.contentDatabaseSourceRows.sourceRowId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, "src-duplicates")); + expect(importedSourceRows.map((row) => row.sourceRowId).sort()).toEqual([ + "entry-dup-1", + "entry-dup-2", + ]); const documents = await db .select({ title: schema.documents.title }) .from(schema.documents) @@ -1963,6 +1972,7 @@ it("records freshly imported Builder row identities even when title and URL keys })); const retryResult = await importBuilderEntries({ database, + sourceId: "src-duplicates", entries, now, sourceTable: "collection-duplicates", @@ -1987,6 +1997,7 @@ it("records freshly imported Builder row identities even when title and URL keys await Promise.all([ importBuilderEntries({ database, + sourceId: "src-duplicates", entries, now, sourceTable: "collection-duplicates", @@ -1995,6 +2006,7 @@ it("records freshly imported Builder row identities even when title and URL keys }), importBuilderEntries({ database, + sourceId: "src-duplicates", entries, now, sourceTable: "collection-duplicates", @@ -2018,6 +2030,7 @@ it("records freshly imported Builder row identities even when title and URL keys const interruptedAttachRecovery = await importBuilderEntries({ database, + sourceId: "src-duplicates", entries, now, sourceTable: "collection-duplicates", @@ -2210,6 +2223,7 @@ it("repairs a legacy organization database into its organization space", async ( await runWithRequestContext({ userEmail: OWNER, orgId }, () => importBuilderEntries({ database, + sourceId: "src-legacy-org", entries, now, sourceTable: "collection-duplicates", diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index 4dddcb7f66..64ad2b986b 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -13,13 +13,12 @@ import { type DocumentPropertyType, } from "../shared/properties.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { getDatabaseById, listPropertiesForDatabaseDocuments, nanoid, normalizedValueJson, - writeBlockFieldContent, - writePrimaryBlocksContent, } from "./_property-utils.js"; export default defineAction({ @@ -84,17 +83,34 @@ export default defineAction({ const target = blocksStorageTarget( parsePropertyOptions(definition.optionsJson), ); - if (target === "document_body") { - await writePrimaryBlocksContent({ documentId, content, now }); - } else { - await writeBlockFieldContent({ - documentId, - propertyId, - ownerEmail: database.ownerEmail, - content, - now, - }); - } + await db.transaction(async (tx) => { + await lockDatabaseMemberships(tx, [membership.id]); + if (target === "document_body") { + await tx + .update(schema.documents) + .set({ content, updatedAt: now }) + .where(eq(schema.documents.id, documentId)); + } else { + await tx + .insert(schema.documentBlockFieldContents) + .values({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId, + propertyId, + content, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.documentBlockFieldContents.documentId, + schema.documentBlockFieldContents.propertyId, + ], + set: { content, updatedAt: now }, + }); + } + }); return { documentId, databaseId: database.id, @@ -113,32 +129,34 @@ export default defineAction({ } const valueJson = normalizedValueJson(type, value); - const [existing] = await db - .select({ id: schema.documentPropertyValues.id }) - .from(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.documentId, documentId), - eq(schema.documentPropertyValues.propertyId, propertyId), - ), - ); - - if (existing) { - await db - .update(schema.documentPropertyValues) - .set({ valueJson, updatedAt: now }) - .where(eq(schema.documentPropertyValues.id, existing.id)); - } else { - await db.insert(schema.documentPropertyValues).values({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, - propertyId, - valueJson, - createdAt: now, - updatedAt: now, - }); - } + await db.transaction(async (tx) => { + await lockDatabaseMemberships(tx, [membership.id]); + const [existing] = await tx + .select({ id: schema.documentPropertyValues.id }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ); + if (existing) { + await tx + .update(schema.documentPropertyValues) + .set({ valueJson, updatedAt: now }) + .where(eq(schema.documentPropertyValues.id, existing.id)); + } else { + await tx.insert(schema.documentPropertyValues).values({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId, + propertyId, + valueJson, + createdAt: now, + updatedAt: now, + }); + } + }); return { documentId, diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index ce000309d8..06c41e01b3 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -18,6 +18,7 @@ import { setFavoriteMembership } from "./_content-favorites.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { provisionContentSpaces } from "./_content-spaces.js"; +import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js"; const MAX_SOURCE_FILES = 500; @@ -616,6 +617,12 @@ export default defineAction({ [item.documentId, item] as const, ), ); + await lockDatabaseMemberships( + tx, + filesItems.map( + (item: typeof schema.contentDatabaseItems.$inferSelect) => item.id, + ), + ); for (const plan of plans.filter((candidate) => !candidate.conflict)) { if (plan.keepContent) continue; const item = itemByDocumentId.get(plan.id); diff --git a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index 72c1abda7b..0821d8003b 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -338,4 +338,21 @@ describe("document database layout", () => { expect(source).toContain("group-hover/footer:opacity-100"); expect(source).toContain("quietUntilHover"); }); + + it("keeps selection controls visible and hides mutations without permission", () => { + const source = readDatabaseSource(); + const selectionBarIndex = source.indexOf(" { + for (const sourceUrl of rowActionSources) { + it(`passes the database system role into ${sourceUrl.pathname.split("/").slice(-1)[0]}`, () => { + const source = readFileSync(sourceUrl, "utf8"); + + expect(source).toContain( + "databaseSystemRole: databaseData.database.systemRole", + ); + expect(source).toContain( + "canRemoveFromDatabase || canDeleteWorkspace ? (", + ); + expect(source).toContain('dbText("removeFromDatabase")'); + expect(source).toContain("removesFavoriteMembership"); + expect(source).toContain( + "databaseItemCanDuplicate(item, isWorkspaceCatalog)", + ); + expect(source).toContain("{canDuplicateRow ? ("); + expect(source).toContain(" { + const source = readFileSync(rowActionSources[0], "utf8"); + + expect(source).toContain('data-database-scroll-surface="table"'); + expect(source).toContain("tabIndex={0}"); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx b/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx new file mode 100644 index 0000000000..422c65cbe1 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseSelectionBar.permissions.test.tsx @@ -0,0 +1,191 @@ +import type { ContentDatabaseItem } from "@shared/api"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { + DatabaseSelectionBar, + databaseSelectionCapabilities, +} from "./DatabaseView"; + +function item(overrides: Partial = {}) { + return { + id: "item-1", + databaseId: "database-1", + document: { + id: "document-1", + accessRole: "viewer", + canView: true, + } as ContentDatabaseItem["document"], + position: 0, + properties: [], + bodyHydration: { + status: "hydrated" as const, + attemptedAt: null, + error: null, + version: null, + }, + ...overrides, + } satisfies ContentDatabaseItem; +} + +function selectionBarMarkup(capabilities: { + canEditSelected: boolean; + canDuplicateSelected: boolean; + canRemoveSelected: boolean; +}) { + return renderToStaticMarkup( + , + ); +} + +describe("database selection permissions", () => { + it("renders count and Clear only for viewers", () => { + const markup = selectionBarMarkup({ + canEditSelected: false, + canDuplicateSelected: false, + canRemoveSelected: false, + }); + + expect(markup).toContain("1 selected"); + expect(markup).toContain("Clear"); + expect(markup).not.toContain("Duplicate"); + expect(markup).not.toContain("Remove"); + }); + + it("renders entry editing without removal and manager removal separately", () => { + const editorMarkup = selectionBarMarkup({ + canEditSelected: true, + canDuplicateSelected: true, + canRemoveSelected: false, + }); + expect(editorMarkup).toContain("Duplicate"); + expect(editorMarkup).not.toContain(">Remove<"); + + const managerMarkup = selectionBarMarkup({ + canEditSelected: true, + canDuplicateSelected: true, + canRemoveSelected: true, + }); + expect(managerMarkup).toContain(">Remove<"); + }); + + it("fails closed for stale and source-backed whole selections", () => { + const localItem = item(); + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: null, + selectedItemIds: [localItem.id, "stale-item"], + selectedItems: [localItem], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }), + ).toEqual({ + selectionComplete: false, + canEditSelected: false, + canDuplicateSelected: false, + canRemoveSelected: false, + }); + + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: null, + selectedItemIds: [localItem.id], + selectedItems: [ + item({ + bodyHydration: { + status: "pending", + attemptedAt: null, + error: null, + version: null, + }, + }), + ], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }).canRemoveSelected, + ).toBe(false); + }); + + it("fails closed when any selected page lacks viewer access", () => { + const inaccessibleItem = item({ + document: { + id: "document-1", + } as ContentDatabaseItem["document"], + }); + + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: null, + selectedItemIds: [inaccessibleItem.id], + selectedItems: [inaccessibleItem], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }), + ).toMatchObject({ + canDuplicateSelected: false, + canRemoveSelected: false, + }); + }); + + it("does not offer membership removal for system databases", () => { + const localItem = item(); + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: "files", + selectedItemIds: [localItem.id], + selectedItems: [localItem], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }).canRemoveSelected, + ).toBe(false); + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: true, + databaseSystemRole: undefined, + selectedItemIds: [localItem.id], + selectedItems: [localItem], + sources: [], + removesFavoriteMembership: false, + isWorkspaceCatalog: false, + }).canRemoveSelected, + ).toBe(false); + expect( + databaseSelectionCapabilities({ + canEdit: true, + canManageDatabase: false, + databaseSystemRole: "favorites", + selectedItemIds: [localItem.id], + selectedItems: [localItem], + sources: [], + removesFavoriteMembership: true, + isWorkspaceCatalog: false, + }).canRemoveSelected, + ).toBe(true); + }); +}); 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 8ac3d43332..82a6d699e5 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 @@ -1,5 +1,6 @@ import type { BuilderCmsModelSummary, + ContentDatabaseItem, ContentDatabaseResponse, ContentDatabaseTableQuery, } from "@shared/api"; @@ -151,7 +152,8 @@ vi.mock("@/hooks/use-content-database", () => ({ useContentDatabasePersonalView: () => ({ data: undefined, isLoading: false }), useUpdateContentDatabasePersonalView: () => benignMutation, useUpdateContentDatabaseView: () => benignMutation, - useDeleteDatabaseItems: () => benignMutation, + useRemoveDatabaseItems: () => benignMutation, + useDuplicateDatabaseItem: () => benignMutation, useDuplicateDatabaseItems: () => benignMutation, useMoveDatabaseItem: () => benignMutation, useBuilderCmsModels: () => builderCmsModelsQuery, @@ -162,6 +164,14 @@ vi.mock("@/hooks/use-document-properties", () => ({ useConfigureDocumentProperty: () => benignMutation, })); +vi.mock("@/hooks/use-content-spaces", () => ({ + useContentSpaces: () => ({ + data: { spaces: [] }, + isLoading: false, + }), + useDeleteContentSpace: () => benignMutation, +})); + vi.mock("@/hooks/use-documents", () => ({ useDocument: () => ({ data: fakeDocument }), seedDatabaseItemDocumentCaches: vi.fn(), @@ -189,6 +199,7 @@ const databaseResponse: ContentDatabaseResponse = { id: "database-1", documentId: "document-1", title: "Test database", + systemRole: null, viewConfig: databaseViewConfig, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -209,6 +220,8 @@ const fakeDocument = { position: 0, isFavorite: false, hideFromSearch: false, + canEdit: true, + canManage: true, database: databaseResponse.database, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -244,6 +257,8 @@ describe("DatabaseView UI regressions", () => { contentDatabaseQueryMock.mockReset(); addItemMutation.mutateAsync.mockReset(); attachSourceMutation.mutateAsync.mockReset(); + benignMutation.mutateAsync.mockReset().mockResolvedValue(undefined); + databaseResponse.items = []; databasePagination.totalItems = 0; databasePagination.hasMore = false; @@ -466,4 +481,76 @@ describe("DatabaseView UI regressions", () => { expect(findButtonByText(container, "Attach")).toBeTruthy(); expect(container.textContent).toContain("Article"); }); + + it("removes the confirmed selection snapshot without clearing newer selections", async () => { + const row = (id: string, title: string): ContentDatabaseItem => ({ + id: `item-${id}`, + databaseId: "database-1", + document: { + id: `document-${id}`, + parentId: "document-1", + title, + content: "", + icon: null, + position: 0, + isFavorite: false, + hideFromSearch: false, + accessRole: "viewer", + canView: true, + canEdit: false, + canManage: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + position: 0, + properties: [], + bodyHydration: { + status: "hydrated", + attemptedAt: null, + error: null, + version: null, + }, + }); + databaseResponse.items = [row("a", "Alpha"), row("b", "Beta")]; + await renderDatabaseView(); + + await act(async () => { + container + .querySelector('[aria-label="Select Alpha"]') + ?.click(); + }); + await act(async () => { + findButtonByText(container, "Remove")?.click(); + }); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + + await act(async () => { + container + .querySelector('[aria-label="Select Beta"]') + ?.click(); + }); + const confirmRemove = [ + ...document.querySelectorAll( + '[role="alertdialog"] button', + ), + ].find((button) => button.textContent?.trim() === "Remove"); + expect(confirmRemove).toBeTruthy(); + + await act(async () => { + confirmRemove?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(benignMutation.mutateAsync).toHaveBeenCalledWith({ + documentId: "document-1", + itemIds: ["item-a"], + }); + expect(container.textContent).toContain("1 selected"); + expect( + container.querySelector( + '[aria-label="Deselect Beta"]', + ), + ).toBeTruthy(); + }); }); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index edab812af2..aa1f079c82 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -176,7 +176,7 @@ import { useContentDatabasePersonalView, useContentDatabases, contentDatabaseQueryKey, - useDeleteDatabaseItems, + useRemoveDatabaseItems, useDisconnectContentDatabaseSource, useDuplicateDatabaseItem, useDuplicateDatabaseItems, @@ -276,6 +276,12 @@ import { VisualEditor } from "../VisualEditor"; import { DatabaseFormView } from "./FormView"; import { DatabaseGalleryView } from "./GalleryView"; import { DatabaseListView } from "./ListView"; +import { + databaseItemCanDuplicate, + databaseItemCanRemoveFromDatabase, + databaseItemHasViewerAccess, + databaseItemIsSourceBacked, +} from "./row-access"; import { DatabaseTimelineView } from "./TimelineView"; export interface DatabaseViewProps { @@ -736,6 +742,7 @@ export function DatabaseView({ const { data: document } = useDocument(databaseDocumentId); if (!document?.database || document.database.id !== databaseId) return null; + const effectiveCanEdit = canEdit && document.canEdit === true; return ( ); @@ -2851,6 +2858,7 @@ function DatabaseTable({ ) : ( setSelectedItemIds([])} + onRemoveSelection={(itemIds) => + setSelectedItemIds((current) => { + const removedIds = new Set(itemIds); + return current.filter((itemId) => !removedIds.has(itemId)); + }) + } onCreateRow={createInlineRow} onCreateGroupedRow={createInlineGroupedRow} onTitleFocusHandled={() => setInlineTitleFocusDocumentId(null)} @@ -3420,6 +3435,41 @@ export function databaseSelectedItems( return visibleItems.filter((item) => selectedIds.has(item.id)); } +export function databaseSelectionCapabilities(args: { + canEdit: boolean; + canManageDatabase: boolean; + databaseSystemRole: string | null | undefined; + selectedItemIds: string[]; + selectedItems: ContentDatabaseItem[]; + sources: ContentDatabaseSource[]; + removesFavoriteMembership: boolean; + isWorkspaceCatalog: boolean; +}) { + const selectionComplete = + args.selectedItems.length === args.selectedItemIds.length; + const canEditSelected = args.canEdit && selectionComplete; + return { + selectionComplete, + canEditSelected, + canDuplicateSelected: + canEditSelected && + args.selectedItems.every((item) => + databaseItemCanDuplicate(item, args.isWorkspaceCatalog), + ), + canRemoveSelected: args.removesFavoriteMembership + ? canEditSelected + : selectionComplete && + args.canManageDatabase && + args.databaseSystemRole === null && + !args.isWorkspaceCatalog && + args.selectedItems.every( + (item) => + databaseItemHasViewerAccess(item) && + !databaseItemIsSourceBacked(item, args.sources), + ), + }; +} + export function databaseBulkEditableProperties(properties: DocumentProperty[]) { return properties.filter( (property) => @@ -5209,6 +5259,7 @@ function DatabaseItemPreview({ function DatabaseTableView({ databaseId, + databaseSystemRole, newRowLabel, properties, groupableProperties, @@ -5217,6 +5268,7 @@ function DatabaseTableView({ sources, databaseDocumentId, canEdit, + canManageDatabase, workspaceCreationPropertyValues, isLoading, isCreating, @@ -5245,6 +5297,7 @@ function DatabaseTableView({ onToggleRowSelection, onToggleAllRowsSelection, onClearSelection, + onRemoveSelection, onClearResultConstraints, onCreateRow, onCreateGroupedRow, @@ -5256,6 +5309,7 @@ function DatabaseTableView({ onOpenPage, }: { databaseId: string; + databaseSystemRole: string | null | undefined; newRowLabel: string; properties: DocumentProperty[]; groupableProperties: DocumentProperty[]; @@ -5264,6 +5318,7 @@ function DatabaseTableView({ sources: ContentDatabaseSource[]; databaseDocumentId: string; canEdit: boolean; + canManageDatabase: boolean; workspaceCreationPropertyValues?: Record; isLoading: boolean; isCreating: boolean; @@ -5303,6 +5358,7 @@ function DatabaseTableView({ onToggleRowSelection: (itemId: string) => void; onToggleAllRowsSelection: () => void; onClearSelection: () => void; + onRemoveSelection: (itemIds: string[]) => void; onClearResultConstraints: () => void; onCreateRow: CreateDatabaseRowHandler; onCreateGroupedRow: ( @@ -5325,7 +5381,7 @@ function DatabaseTableView({ databaseId, databaseDocumentId, ); - const deleteItems = useDeleteDatabaseItems(databaseDocumentId); + const removeItems = useRemoveDatabaseItems(databaseDocumentId); const [draggedItemId, setDraggedItemId] = useState(null); const [dropTargetItemId, setDropTargetItemId] = useState(null); const [draggedPropertyId, setDraggedPropertyId] = useState( @@ -5335,17 +5391,45 @@ function DatabaseTableView({ useState(null); const [dragPreview, setDragPreview] = useState(null); - const [confirmDeleteSelectedOpen, setConfirmDeleteSelectedOpen] = + const [confirmRemoveSelectedOpen, setConfirmRemoveSelectedOpen] = useState(false); + const [removeSelectedSnapshotIds, setRemoveSelectedSnapshotIds] = useState< + string[] + >([]); const [isDuplicatingSelected, setIsDuplicatingSelected] = useState(false); const selectedCount = selectedItemIds.length; const selectableCount = items.length; const selectedIdSet = new Set(selectedItemIds); const selectedItems = databaseSelectedItems(items, selectedItemIds); + const removeSelectedSnapshot = databaseSelectedItems( + items, + removeSelectedSnapshotIds, + ); const removesFavoriteMembership = contentSpaces.data?.favoritesDocumentId === databaseDocumentId; const isWorkspaceCatalog = contentSpaces.data?.catalogDocumentId === databaseDocumentId; + const { canEditSelected, canDuplicateSelected, canRemoveSelected } = + databaseSelectionCapabilities({ + canEdit, + canManageDatabase, + databaseSystemRole, + selectedItemIds, + selectedItems, + sources, + removesFavoriteMembership, + isWorkspaceCatalog, + }); + const canConfirmRemoveSelected = databaseSelectionCapabilities({ + canEdit, + canManageDatabase, + databaseSystemRole, + selectedItemIds: removeSelectedSnapshotIds, + selectedItems: removeSelectedSnapshot, + sources, + removesFavoriteMembership, + isWorkspaceCatalog, + }).canRemoveSelected; const bulkEditableProperties = databaseBulkEditableProperties(properties); const groups = databaseVisibleGroups( databaseViewItemGroups(items, groupableProperties, groupByPropertyId), @@ -5570,17 +5654,17 @@ function DatabaseTableView({ } } - async function deleteSelectedRows() { - if (selectedItems.length === 0) return; - const selectedSnapshot = selectedItems; - setConfirmDeleteSelectedOpen(false); + async function removeSelectedRows(selectedSnapshot: ContentDatabaseItem[]) { + if (selectedSnapshot.length === 0) return; + setConfirmRemoveSelectedOpen(false); + setRemoveSelectedSnapshotIds([]); try { - await deleteItems.mutateAsync({ + await removeItems.mutateAsync({ documentId: databaseDocumentId, itemIds: selectedSnapshot.map((item) => item.id), }); - onClearSelection(); + onRemoveSelection(selectedSnapshot.map((item) => item.id)); onDeletedPreviewItems(selectedSnapshot); await queryClient.invalidateQueries({ queryKey: [ @@ -5593,7 +5677,7 @@ function DatabaseTableView({ queryKey: ["action", "list-documents"], }); } catch (err) { - toast.error(dbText("failedToDeleteSelectedRows"), { + toast.error(dbText("failedToRemoveEverySelectedRowFromDatabase"), { description: err instanceof Error ? err.message : dbText("somethingWentWrong"), }); @@ -5601,7 +5685,12 @@ function DatabaseTableView({ } async function duplicateSelectedRows() { - if (selectedItems.length === 0 || isDuplicatingSelected) return; + if ( + !canDuplicateSelected || + selectedItems.length === 0 || + isDuplicatingSelected + ) + return; const selectedSnapshot = selectedItems; setIsDuplicatingSelected(true); @@ -5640,7 +5729,7 @@ function DatabaseTableView({ property: DocumentProperty, operation: DatabaseBulkPropertyValueOperation, ) { - if (selectedItems.length === 0) return; + if (!canEditSelected || selectedItems.length === 0) return; const selectedSnapshot = selectedItems; let updatedCount = 0; @@ -5677,275 +5766,289 @@ function DatabaseTableView({ } return ( -
+
-
- {selectedCount > 0 ? ( - 0 ? ( + void duplicateSelectedRows()} + onRemoveSelected={() => { + if (removesFavoriteMembership) { + void removeSelectedRows(selectedItems); + return; } - deleteDisabled={deleteItems.isPending} - removesFavoriteMembership={removesFavoriteMembership} - updateDisabled={setProperty.isPending} - onClearSelection={onClearSelection} - onSetPropertyValue={setSelectedPropertyValue} - onDuplicateSelected={() => void duplicateSelectedRows()} - onDeleteSelected={() => { - if (removesFavoriteMembership) { - void deleteSelectedRows(); - return; - } - setConfirmDeleteSelectedOpen(true); - }} - /> - ) : null} -
item.id)); + setConfirmRemoveSelectedOpen(true); }} - > - - onResizeColumn("name", DEFAULT_NAME_COLUMN_WIDTH, event) - } - /> - {properties.map((property) => { - return ( - - startPropertyPointerDrag(property, event) - } - onResize={(event) => - onResizeColumn( - property.definition.id, - DEFAULT_PROPERTY_COLUMN_WIDTH, - event, - ) - } - /> - ); - })} - {canEdit ? ( -
- -
- ) : null} -
- - {databaseTableShouldShowBlockingLoader(isLoading, items.length) ? ( -
- - {dbText("loadingDatabase")} -
- ) : ( - <> - {databaseViewHasNoMatchingPages( - items.length, - hasSearch, - activeFilters.length, - ) ? ( - + /> + ) : null} +
+
+
+ + onResizeColumn("name", DEFAULT_NAME_COLUMN_WIDTH, event) + } + /> + {properties.map((property) => { + return ( + + startPropertyPointerDrag(property, event) + } + onResize={(event) => + onResizeColumn( + property.definition.id, + DEFAULT_PROPERTY_COLUMN_WIDTH, + event, + ) + } + /> + ); + })} + {canEdit ? ( +
+ +
) : null} - {grouped - ? groups.map((group) => ( - + + {databaseTableShouldShowBlockingLoader(isLoading, items.length) ? ( +
+ + {dbText("loadingDatabase")} +
+ ) : ( + <> + {databaseViewHasNoMatchingPages( + items.length, + hasSearch, + activeFilters.length, + ) ? ( + + ) : null} + {grouped + ? groups.map((group) => ( + + onGroupCollapsedChange(group.id, collapsed) + } + onToggleCheckbox={toggleCheckboxCell} + onToggleRowSelection={onToggleRowSelection} + onPreview={onPreview} + onDeletedPreviewItem={onDeletedPreviewItem} + onOpenPage={onOpenPage} + /> + )) + : items.map((item, index) => ( + 0} + canMoveDown={ + rowsAreManuallyOrdered && index < items.length - 1 + } + selected={selectedIdSet.has(item.id)} + isDragging={draggedItemId === item.id} + isDropTarget={ + !!draggedItemId && + dropTargetItemId === item.id && + draggedItemId !== item.id + } + startEditingTitle={ + focusedTitleDocumentId === item.document.id + } + onDragHandlePointerDown={(event) => + startRowDrag(item.id, event) + } + onToggleCheckbox={(property) => + void toggleCheckboxCell(item, property) + } + wrapCells={wrapCells} + rowDensity={rowDensity} + onToggleSelected={() => onToggleRowSelection(item.id)} + onPreviewItem={onPreview} + onDeletedPreviewItem={onDeletedPreviewItem} + onTitleEditStarted={onTitleFocusHandled} + onPreview={() => onPreview(item)} + onOpenPage={() => onOpenPage(item)} + /> + ))} + {canEdit && !grouped ? ( + isWorkspaceCatalog ? ( + - onGroupCollapsedChange(group.id, collapsed) - } - onToggleCheckbox={toggleCheckboxCell} - onToggleRowSelection={onToggleRowSelection} - onPreview={onPreview} - onDeletedPreviewItem={onDeletedPreviewItem} - onOpenPage={onOpenPage} + propertyValues={workspaceCreationPropertyValues} + actionColumnWidth={actionColumnWidth} /> - )) - : items.map((item, index) => ( - 0} - canMoveDown={ - rowsAreManuallyOrdered && index < items.length - 1 - } - selected={selectedIdSet.has(item.id)} - isDragging={draggedItemId === item.id} - isDropTarget={ - !!draggedItemId && - dropTargetItemId === item.id && - draggedItemId !== item.id - } - startEditingTitle={ - focusedTitleDocumentId === item.document.id - } - onDragHandlePointerDown={(event) => - startRowDrag(item.id, event) - } - onToggleCheckbox={(property) => - void toggleCheckboxCell(item, property) - } - wrapCells={wrapCells} rowDensity={rowDensity} - onToggleSelected={() => onToggleRowSelection(item.id)} - onPreviewItem={onPreview} - onDeletedPreviewItem={onDeletedPreviewItem} - onTitleEditStarted={onTitleFocusHandled} - onPreview={() => onPreview(item)} - onOpenPage={() => onOpenPage(item)} + disabled={isCreating} + isPending={isCreating} + onCreate={onCreateRow} + actionColumnWidth={actionColumnWidth} /> - ))} - {canEdit && !grouped ? ( - isWorkspaceCatalog ? ( - - ) : ( - - ) - ) : null} - {cleanDefaultTable ? ( - - ) : null} - - - )} + + )} +
{ + setConfirmRemoveSelectedOpen(open); + if (!open) setRemoveSelectedSnapshotIds([]); + }} > - {dbText("deleteSelectedRows")} + + {dbText("removeSelectedRowsFromDatabaseQuestion")} + - {selectedCount} selected row{selectedCount === 1 ? "" : "s"} and - any sub-pages will be permanently deleted. This cannot be undone. + {dbText("removeSelectedRowsFromDatabaseDescription")} - Cancel + {dbText("cancel")} void deleteSelectedRows()} + disabled={removeItems.isPending || !canConfirmRemoveSelected} + onClick={() => void removeSelectedRows(removeSelectedSnapshot)} > - {deleteItems.isPending ? "Deleting..." : "Delete"} + {removeItems.isPending ? dbText("removing") : dbText("remove")} @@ -15355,26 +15458,30 @@ function DatabaseNameHeader({ ); } -function DatabaseSelectionBar({ +export function DatabaseSelectionBar({ selectedCount, - canEdit, + canEditSelected, + canDuplicateSelected, + canRemoveSelected, properties, selectedItems, duplicateDisabled, - deleteDisabled, + removeDisabled, removesFavoriteMembership, updateDisabled, onClearSelection, onSetPropertyValue, onDuplicateSelected, - onDeleteSelected, + onRemoveSelected, }: { selectedCount: number; - canEdit: boolean; + canEditSelected: boolean; + canDuplicateSelected: boolean; + canRemoveSelected: boolean; properties: DocumentProperty[]; selectedItems: ContentDatabaseItem[]; duplicateDisabled: boolean; - deleteDisabled: boolean; + removeDisabled: boolean; removesFavoriteMembership: boolean; updateDisabled: boolean; onClearSelection: () => void; @@ -15383,7 +15490,7 @@ function DatabaseSelectionBar({ operation: DatabaseBulkPropertyValueOperation, ) => Promise; onDuplicateSelected: () => void; - onDeleteSelected: () => void; + onRemoveSelected: () => void; }) { return (
@@ -15391,46 +15498,48 @@ function DatabaseSelectionBar({ {selectedCount} selected
- {canEdit ? ( - <> - - - - + {canEditSelected ? ( + + ) : null} + {canDuplicateSelected ? ( + + ) : null} + {canRemoveSelected ? ( + ) : null}