mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
fix: advisory lock for page move
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user