fix: db lock operations (#2479)

* fix: advisory lock for page move

* fix: lock role count check
This commit is contained in:
Philip Okugbe
2026-09-08 13:16:56 +01:00
committed by GitHub
parent 949072744d
commit 5792fc7ca2
6 changed files with 348 additions and 205 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,22 +396,33 @@ 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(
currentRootPage.id,
{ includeContent: false, trx },
);
const accessiblePages = await this.filterAccessibleTreePages( const accessiblePages = await this.filterAccessibleTreePages(
allPages, allPages,
rootPage.id, currentRootPage.id,
userId, userId,
rootPage.spaceId, currentRootPage.spaceId,
); );
const accessibleIds = new Set(accessiblePages.map((p) => p.id)); const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter( const pagesToOrphan = allPages.filter(
(p) => (p) =>
!accessibleIds.has(p.id) && !accessibleIds.has(p.id) &&
@@ -414,12 +430,12 @@ export class PageService {
accessibleIds.has(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,20 +843,46 @@ export class PageService {
throw new BadRequestException('A page cannot be its own parent'); throw new BadRequestException('A page cannot be its own parent');
} }
await executeTx(this.db, async (trx) => {
await this.pageRepo.lockPageHierarchySpaces(
[movedPage.spaceId],
trx,
);
const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
if (!currentPage || currentPage.deletedAt) {
throw new NotFoundException('Moved page not found');
}
if (currentPage.spaceId !== movedPage.spaceId) {
throw new ConflictException('Page location changed; retry the move');
}
let parentPageId = null; let parentPageId = null;
if (movedPage.parentPageId === dto.parentPageId) { if (currentPage.parentPageId === dto.parentPageId) {
parentPageId = undefined; parentPageId = undefined;
} else { } else {
// changing the page's parent
if (dto.parentPageId) { if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId); const parentPage = await this.pageRepo.findById(dto.parentPageId, {
trx,
});
if ( if (
!parentPage || !parentPage ||
parentPage.deletedAt || parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId parentPage.spaceId !== currentPage.spaceId
) { ) {
throw new NotFoundException('Parent page not found'); 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; parentPageId = parentPage.id;
} }
} }
@@ -849,7 +893,9 @@ export class PageService {
parentPageId: parentPageId, parentPageId: parentPageId,
}, },
dto.pageId, dto.pageId,
trx,
); );
});
} }
async getPageBreadCrumbs(childPageId: string) { async getPageBreadCrumbs(childPageId: string) {
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto'; import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types'; import { Space, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto'; import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
@@ -218,41 +218,18 @@ export class SpaceMemberService {
dto: RemoveSpaceMemberDto, dto: RemoveSpaceMemberDto,
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId); const memberTypeId = dto.userId
if (!space) { ? { userId: dto.userId }
throw new NotFoundException('Space not found'); : dto.groupId
} ? { groupId: dto.groupId }
: null;
let spaceMember: SpaceMember = null; if (!memberTypeId) {
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException( throw new BadRequestException(
'Please provide a valid userId or groupId to remove', 'Please provide a valid userId or groupId to remove',
); );
} }
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
let affectedUserIds: string[] = []; let affectedUserIds: string[] = [];
if (dto.userId) { if (dto.userId) {
affectedUserIds = [dto.userId]; affectedUserIds = [dto.userId];
@@ -262,7 +239,29 @@ export class SpaceMemberService {
); );
} }
await executeTx(this.db, async (trx) => { const { space, spaceMember } = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await this.spaceMemberRepo.removeSpaceMemberById( await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id, spaceMember.id,
dto.spaceId, dto.spaceId,
@@ -280,6 +279,8 @@ export class SpaceMemberService {
dto.spaceId, dto.spaceId,
{ trx }, { trx },
); );
return { space, spaceMember };
}); });
this.auditService.log({ this.auditService.log({
@@ -304,49 +305,41 @@ export class SpaceMemberService {
dto: UpdateSpaceMemberRoleDto, dto: UpdateSpaceMemberRoleDto,
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId); const memberTypeId = dto.userId
if (!space) { ? { userId: dto.userId }
throw new NotFoundException('Space not found'); : dto.groupId
} ? { groupId: dto.groupId }
: null;
let spaceMember: SpaceMember = null; if (!memberTypeId) {
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException( throw new BadRequestException(
'Please provide a valid userId or groupId to remove', 'Please provide a valid userId or groupId to remove',
); );
} }
const result = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) { if (!spaceMember) {
throw new NotFoundException('Space membership not found'); throw new NotFoundException('Space membership not found');
} }
if (spaceMember.role === dto.role) { if (spaceMember.role === dto.role) {
return; return { changed: false, space, spaceMember };
} }
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
if (spaceMember.role === SpaceRole.ADMIN) { if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx); await this.validateLastAdmin(dto.spaceId, trx);
} }
@@ -357,8 +350,16 @@ export class SpaceMemberService {
dto.spaceId, dto.spaceId,
trx, trx,
); );
return { changed: true, space, spaceMember };
}); });
if (!result.changed) {
return;
}
const { space, spaceMember } = result;
this.auditService.log({ this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED, event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER, resourceType: AuditResource.SPACE_MEMBER,
@@ -387,7 +388,7 @@ export class SpaceMemberService {
spaceId, spaceId,
trx, trx,
); );
if (spaceOwnerCount === 1) { if (spaceOwnerCount <= 1) {
throw new BadRequestException( throw new BadRequestException(
'There must be at least one space admin with full access', 'There must be at least one space admin with full access',
); );
@@ -747,15 +747,25 @@ export class WorkspaceService {
userRoleDto: UpdateWorkspaceUserRoleDto, userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string, workspaceId: string,
) { ) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase(); const newRole = userRoleDto.role.toLowerCase();
const result = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(
userRoleDto.userId,
workspaceId,
{ trx },
);
if (!user) { if (!user) {
throw new BadRequestException('Workspace member not found'); throw new BadRequestException('Workspace member not found');
} }
// prevent ADMIN from managing OWNER role
if ( if (
isAdminActingOnOwner(authUser.role, newRole) || isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role) isAdminActingOnOwner(authUser.role, user.role)
@@ -764,18 +774,15 @@ export class WorkspaceService {
} }
if (user.role === newRole) { if (user.role === newRole) {
return user; return { changed: false, user };
} }
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId( if (
UserRole.OWNER, user.role === UserRole.OWNER &&
workspaceId, !user.deletedAt &&
); !user.deactivatedAt
) {
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) { await this.validateLastWorkspaceOwner(workspaceId, trx);
throw new BadRequestException(
'There must be at least one workspace owner',
);
} }
await this.userRepo.updateUser( await this.userRepo.updateUser(
@@ -784,8 +791,18 @@ export class WorkspaceService {
}, },
user.id, user.id,
workspaceId, workspaceId,
trx,
); );
return { changed: true, user };
});
if (!result.changed) {
return result.user;
}
const { user } = result;
this.auditService.log({ this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED, event: AuditEvent.USER_ROLE_CHANGED,
resourceType: AuditResource.USER, resourceType: AuditResource.USER,
@@ -848,8 +865,16 @@ export class WorkspaceService {
userId: string, userId: string,
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId); const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) { if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found'); throw new BadRequestException('Workspace member not found');
} }
@@ -869,19 +894,9 @@ export class WorkspaceService {
} }
if (user.role === UserRole.OWNER) { if (user.role === UserRole.OWNER) {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId( await this.validateLastWorkspaceOwner(workspaceId, trx);
UserRole.OWNER,
workspaceId,
);
if (workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
} }
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ deactivatedAt: new Date() }, { deactivatedAt: new Date() },
userId, userId,
@@ -889,6 +904,8 @@ export class WorkspaceService {
trx, trx,
); );
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
}); });
this.auditService.log({ this.auditService.log({
@@ -951,21 +968,18 @@ export class WorkspaceService {
userId: string, userId: string,
workspaceId: string, workspaceId: string,
): Promise<void> { ): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId); const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
if (!user || user.deletedAt) { withLock: true,
throw new BadRequestException('Workspace member not found'); trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
} }
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId( const user = await this.userRepo.findById(userId, workspaceId, { trx });
UserRole.OWNER, if (!user || user.deletedAt) {
workspaceId, throw new BadRequestException('Workspace member not found');
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
} }
if (authUser.id === userId) { if (authUser.id === userId) {
@@ -973,10 +987,15 @@ export class WorkspaceService {
} }
if (isAdminActingOnOwner(authUser.role, user.role)) { if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException('You cannot delete a user with owner role'); throw new BadRequestException(
'You cannot delete a user with owner role',
);
}
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
} }
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
name: 'Deleted user', name: 'Deleted user',
@@ -1009,6 +1028,8 @@ export class WorkspaceService {
}); });
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
}); });
this.auditService.log({ this.auditService.log({
@@ -1030,4 +1051,20 @@ export class WorkspaceService {
// empty // empty
} }
} }
private async validateLastWorkspaceOwner(
workspaceId: string,
trx: KyselyTransaction,
): Promise<void> {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
trx,
);
if (workspaceOwnerCount <= 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
}
} }
@@ -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:
@@ -25,7 +25,11 @@ export class SpaceRepo {
async findById( async findById(
spaceId: string, spaceId: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, opts?: {
includeMemberCount?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Space> { ): Promise<Space> {
const db = dbOrTx(this.db, opts?.trx); const db = dbOrTx(this.db, opts?.trx);
@@ -41,6 +45,11 @@ export class SpaceRepo {
} else { } else {
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`); query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
} }
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
return query.executeTakeFirst(); return query.executeTakeFirst();
} }
@@ -145,12 +145,16 @@ export class UserRepo {
async roleCountByWorkspaceId( async roleCountByWorkspaceId(
role: string, role: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> { ): Promise<number> {
const { count } = await this.db const db = dbOrTx(this.db, trx);
const { count } = await db
.selectFrom('users') .selectFrom('users')
.select((eb) => eb.fn.count('role').as('count')) .select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role) .where('role', '=', role)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst(); .executeTakeFirst();
return count as number; return count as number;