fix: advisory lock for page move

This commit is contained in:
Philipinho
2026-09-08 02:43:04 +01:00
parent 949072744d
commit 1d467d8c35
2 changed files with 149 additions and 57 deletions
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
@@ -20,7 +21,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto'; import { MovePageDto } from '../dto/move-page.dto';
import { generateSlugId } from '../../../common/helpers'; import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers'; import { getPageTitle } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils'; import { dbOrTx, executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid'; import { v7 as uuid7 } from 'uuid';
import { import {
@@ -174,10 +175,14 @@ export class PageService {
return page; return page;
} }
async nextPagePosition(spaceId: string, parentPageId?: string) { async nextPagePosition(
spaceId: string,
parentPageId?: string,
trx?: KyselyTransaction,
) {
let pagePosition: string; let pagePosition: string;
const lastPageQuery = this.db const lastPageQuery = dbOrTx(this.db, trx)
.selectFrom('pages') .selectFrom('pages')
.select(['position']) .select(['position'])
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
@@ -391,35 +396,46 @@ export class PageService {
} }
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) { async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
let childPageIds: string[] = []; return executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[rootPage.spaceId, spaceId],
trx,
);
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, { const currentRootPage = await this.pageRepo.findById(rootPage.id, {
includeContent: false, trx,
}); });
if (!currentRootPage || currentRootPage.deletedAt) {
throw new NotFoundException('Page to move not found');
}
if (currentRootPage.spaceId !== rootPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
// Filter to only accessible pages while maintaining tree integrity const allPages = await this.pageRepo.getPageAndDescendants(
const accessiblePages = await this.filterAccessibleTreePages( currentRootPage.id,
allPages, { includeContent: false, trx },
rootPage.id, );
userId, const accessiblePages = await this.filterAccessibleTreePages(
rootPage.spaceId, allPages,
); currentRootPage.id,
const accessibleIds = new Set(accessiblePages.map((p) => p.id)); userId,
currentRootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space) // Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) { for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition( const orphanPosition = await this.nextPagePosition(
rootPage.spaceId, currentRootPage.spaceId,
null, null,
trx,
); );
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition }, { parentPageId: null, position: orphanPosition },
@@ -429,16 +445,18 @@ export class PageService {
} }
// Update root page // Update root page
const nextPosition = await this.nextPagePosition(spaceId); const nextPosition = await this.nextPagePosition(spaceId, null, trx);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ spaceId, parentPageId: null, position: nextPosition }, { spaceId, parentPageId: null, position: nextPosition },
rootPage.id, currentRootPage.id,
trx, trx,
); );
const pageIdsToMove = accessiblePages.map((p) => p.id); const pageIdsToMove = accessiblePages.map((p) => p.id);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id); const childPageIds = pageIdsToMove.filter(
(id) => id !== currentRootPage.id,
);
if (pageIdsToMove.length > 1) { if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root) // Update sub pages (all accessible pages except root)
@@ -501,7 +519,7 @@ export class PageService {
{ {
pageIds: pageIdsToMove, pageIds: pageIdsToMove,
spaceId, spaceId,
workspaceId: rootPage.workspaceId, workspaceId: currentRootPage.workspaceId,
}, },
{ {
attempts: 2, attempts: 2,
@@ -512,9 +530,9 @@ export class PageService {
}, },
); );
} }
});
return { childPageIds }; return { childPageIds };
});
} }
async duplicatePage( async duplicatePage(
@@ -825,31 +843,59 @@ export class PageService {
throw new BadRequestException('A page cannot be its own parent'); throw new BadRequestException('A page cannot be its own parent');
} }
let parentPageId = null; await executeTx(this.db, async (trx) => {
if (movedPage.parentPageId === dto.parentPageId) { await this.pageRepo.lockPageHierarchySpaces(
parentPageId = undefined; [movedPage.spaceId],
} else { trx,
// changing the page's parent );
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
}
}
await this.pageRepo.updatePage( const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
{ if (!currentPage || currentPage.deletedAt) {
position: dto.position, throw new NotFoundException('Moved page not found');
parentPageId: parentPageId, }
}, if (currentPage.spaceId !== movedPage.spaceId) {
dto.pageId, throw new ConflictException('Page location changed; retry the move');
); }
let parentPageId = null;
if (currentPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
} else {
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId, {
trx,
});
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== currentPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
if (
await this.pageRepo.isPageDescendant(
dto.pageId,
parentPage.id,
trx,
)
) {
throw new BadRequestException(
'A page cannot be moved under its descendant',
);
}
parentPageId = parentPage.id;
}
}
await this.pageRepo.updatePage(
{
position: dto.position,
parentPageId: parentPageId,
},
dto.pageId,
trx,
);
});
} }
async getPageBreadCrumbs(childPageId: string) { async getPageBreadCrumbs(childPageId: string) {
@@ -161,6 +161,22 @@ export class PageRepo {
return result; return result;
} }
async lockPageHierarchySpaces(
spaceIds: string[],
trx: KyselyTransaction,
): Promise<void> {
const sortedSpaceIds = [...new Set(spaceIds)].sort();
for (const spaceId of sortedSpaceIds) {
await sql`
SELECT pg_advisory_xact_lock(
hashtext('page-hierarchy'),
hashtext(${spaceId})
)
`.execute(trx);
}
}
async insertPage( async insertPage(
insertablePage: InsertablePage, insertablePage: InsertablePage,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -489,9 +505,9 @@ export class PageRepo {
async getPageAndDescendants( async getPageAndDescendants(
parentPageId: string, parentPageId: string,
opts: { includeContent: boolean }, opts: { includeContent: boolean; trx?: KyselyTransaction },
) { ) {
return this.db return dbOrTx(this.db, opts.trx)
.withRecursive('page_hierarchy', (db) => .withRecursive('page_hierarchy', (db) =>
db db
.selectFrom('pages') .selectFrom('pages')
@@ -535,6 +551,36 @@ export class PageRepo {
.execute(); .execute();
} }
async isPageDescendant(
ancestorPageId: string,
descendantPageId: string,
trx?: KyselyTransaction,
): Promise<boolean> {
const result = await dbOrTx(this.db, trx)
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.where('id', '=', descendantPageId)
.union((exp) =>
exp
.selectFrom('pages as parent')
.select(['parent.id', 'parent.parentPageId'])
.innerJoin(
'page_ancestors as ancestor',
'ancestor.parentPageId',
'parent.id',
),
),
)
.selectFrom('page_ancestors')
.select('id')
.where('id', '=', ancestorPageId)
.executeTakeFirst();
return Boolean(result);
}
/** /**
* Get page and all descendants, excluding restricted pages and their subtrees. * Get page and all descendants, excluding restricted pages and their subtrees.
* More efficient than getPageAndDescendants + filtering because: * More efficient than getPageAndDescendants + filtering because: