fix: stop cyclic descendant traversal before side effects

This commit is contained in:
Philipinho
2026-09-02 12:26:27 +01:00
parent e3843e7178
commit 9dad90c142
4 changed files with 453 additions and 101 deletions
@@ -133,24 +133,27 @@ export class PageService {
ydoc = createYdocFromJson(prosemirrorJson);
}
const page = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
createPageDto.spaceId,
parentPageId,
),
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
isBase,
content,
textContent,
ydoc,
}, trx);
const page = await this.pageRepo.insertPage(
{
slugId: generateSlugId(),
title: createPageDto.title,
position: await this.nextPagePosition(
createPageDto.spaceId,
parentPageId,
),
icon: createPageDto.icon,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
isBase,
content,
textContent,
ydoc,
},
trx,
);
if (trx) {
// Add the watcher inside the caller's transaction so the async worker
@@ -1034,19 +1037,29 @@ export class PageService {
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
),
)
.selectFrom('page_descendants')
.selectAll()
.select(['id', 'isCycle'])
.execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
@@ -5,6 +5,8 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { assertAcyclicPageTraversal } from '../../../database/helpers/page-hierarchy-cycle';
import { sql } from 'kysely';
const DEFAULT_RETENTION_DAYS = 30;
@@ -76,19 +78,29 @@ export class TrashCleanupService {
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.select([
'id',
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
sql<boolean>`false`.as('isCycle'),
])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
.select([
'p.id',
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('pd.isCycle', '=', false),
),
)
.selectFrom('page_descendants')
.selectAll()
.select(['id', 'isCycle'])
.execute();
assertAcyclicPageTraversal(descendants, pageId);
const pageIds = descendants.map((d) => d.id);
this.logger.debug(