mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
531d2151a1 | ||
|
|
5b85464561 |
@@ -0,0 +1,109 @@
|
||||
import React, {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onAuthenticationFailedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
HocuspocusContext,
|
||||
HocuspocusRoomContext,
|
||||
} from "@hocuspocus/provider-react";
|
||||
|
||||
type CollabRoomProps = {
|
||||
name: string;
|
||||
token: string;
|
||||
flushDelay?: number;
|
||||
onStateless?: (data: onStatelessParameters) => void;
|
||||
onAuthenticationFailed?: (data: onAuthenticationFailedParameters) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the library HocuspocusRoom, whose unmount defers provider.destroy()
|
||||
* by a timeout (a StrictMode grace we don't need): a room for the same
|
||||
* document mounting in the same commit then attaches while the old provider is
|
||||
* still registered on the shared socket and crashes with "Cannot attach two
|
||||
* providers with the same effective name". Destroying synchronously preserves
|
||||
* unmount-before-mount ordering, and attach evicts whatever a previously
|
||||
* interrupted teardown left registered.
|
||||
*/
|
||||
export default function CollabRoom({
|
||||
name,
|
||||
token,
|
||||
flushDelay,
|
||||
onStateless,
|
||||
onAuthenticationFailed,
|
||||
children,
|
||||
}: CollabRoomProps) {
|
||||
const context = useContext(HocuspocusContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CollabRoom must be used within HocuspocusProviderWebsocketComponent",
|
||||
);
|
||||
}
|
||||
const { websocketProvider } = context;
|
||||
|
||||
const [provider, setProvider] = useState(
|
||||
() =>
|
||||
new HocuspocusProvider({ name, websocketProvider, token, flushDelay }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
provider.configuration.name !== name ||
|
||||
provider.configuration.token !== token ||
|
||||
provider.configuration.websocketProvider !== websocketProvider
|
||||
) {
|
||||
provider.destroy();
|
||||
setProvider(
|
||||
new HocuspocusProvider({ name, websocketProvider, token, flushDelay }),
|
||||
);
|
||||
}
|
||||
}, [name, token, websocketProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
const providerMap = websocketProvider.configuration.providerMap;
|
||||
const existing = providerMap.get(provider.effectiveName);
|
||||
if (existing && existing !== provider) {
|
||||
try {
|
||||
existing.destroy();
|
||||
} catch {
|
||||
// a broken teardown is exactly why it leaked; the delete below recovers
|
||||
}
|
||||
providerMap.delete(provider.effectiveName);
|
||||
}
|
||||
provider.attach();
|
||||
return () => provider.destroy();
|
||||
}, [provider]);
|
||||
|
||||
const handlersRef = useRef({ onStateless, onAuthenticationFailed });
|
||||
handlersRef.current = { onStateless, onAuthenticationFailed };
|
||||
|
||||
useEffect(() => {
|
||||
const statelessListener = (data: onStatelessParameters) =>
|
||||
handlersRef.current.onStateless?.(data);
|
||||
const authenticationFailedListener = (
|
||||
data: onAuthenticationFailedParameters,
|
||||
) => handlersRef.current.onAuthenticationFailed?.(data);
|
||||
provider.on("stateless", statelessListener);
|
||||
provider.on("authenticationFailed", authenticationFailedListener);
|
||||
return () => {
|
||||
provider.off("stateless", statelessListener);
|
||||
provider.off("authenticationFailed", authenticationFailedListener);
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const contextValue = useMemo(() => ({ provider }), [provider]);
|
||||
|
||||
return (
|
||||
<HocuspocusRoomContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HocuspocusRoomContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
HocuspocusProviderWebsocketComponent,
|
||||
HocuspocusRoom,
|
||||
useHocuspocusEvent,
|
||||
useHocuspocusProvider,
|
||||
} from "@hocuspocus/provider-react";
|
||||
import CollabRoom from "@/features/editor/collab-room.tsx";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -145,7 +145,7 @@ export default function PageEditor({
|
||||
<TransclusionLookupProvider>
|
||||
{collabQuery?.token ? (
|
||||
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
|
||||
<HocuspocusRoom
|
||||
<CollabRoom
|
||||
name={`page.${pageId}`}
|
||||
token={collabQuery.token}
|
||||
flushDelay={500}
|
||||
@@ -158,7 +158,7 @@ export default function PageEditor({
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</HocuspocusRoom>
|
||||
</CollabRoom>
|
||||
</HocuspocusProviderWebsocketComponent>
|
||||
) : (
|
||||
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:integration": "jest --config test/jest-integration.json --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
|
||||
@@ -55,10 +55,6 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -133,27 +129,24 @@ 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
|
||||
@@ -874,9 +867,6 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
sql<number>`0`.as('traversalDepth'),
|
||||
])
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
@@ -893,28 +883,13 @@ export class PageService {
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
sql<number>`pa.traversal_depth + 1`.as('traversalDepth'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pa.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'isBase',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb
|
||||
.exists(
|
||||
@@ -926,12 +901,9 @@ export class PageService {
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.orderBy('traversalDepth', 'desc')
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, childPageId);
|
||||
|
||||
return ancestors.map(stripPageTraversalMetadata);
|
||||
return ancestors.reverse();
|
||||
}
|
||||
|
||||
async getRecentSpacePages(
|
||||
@@ -1037,29 +1009,19 @@ export class PageService {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.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),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.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,11 +5,6 @@ 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,
|
||||
PageHierarchyCycleError,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@@ -47,35 +42,17 @@ export class TrashCleanupService {
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
.orderBy('id')
|
||||
.execute();
|
||||
|
||||
for (const page of oldDeletedPages) {
|
||||
let pageIds: string[];
|
||||
|
||||
try {
|
||||
const ancestors = await this.getPageAncestors(page.id);
|
||||
assertAcyclicPageTraversal(ancestors, page.id);
|
||||
|
||||
const descendants = await this.getPageDescendants(page.id);
|
||||
assertAcyclicPageTraversal(descendants, page.id);
|
||||
pageIds = descendants.map((descendant) => descendant.id);
|
||||
await this.cleanupPage(page.id);
|
||||
totalCleaned++;
|
||||
} catch (error) {
|
||||
if (error instanceof PageHierarchyCycleError) {
|
||||
this.logCleanupError(page.id, error);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
totalCleaned += await this.cleanupPage(page.id, pageIds);
|
||||
} catch (error) {
|
||||
this.logCleanupError(page.id, error);
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,66 +70,27 @@ export class TrashCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPageAncestors(pageId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'parentPageId',
|
||||
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',
|
||||
'p.parentPageId',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('pa.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async getPageDescendants(pageId: string) {
|
||||
private async cleanupPage(pageId: string) {
|
||||
// Get all descendants using recursive CTE (including the page itself)
|
||||
return this.db
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.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),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async cleanupPage(pageId: string, pageIds: string[]) {
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
this.logger.debug(
|
||||
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
|
||||
);
|
||||
@@ -176,24 +114,14 @@ export class TrashCleanupService {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.db
|
||||
.deleteFrom('pages')
|
||||
.where('id', 'in', pageIds)
|
||||
.executeTakeFirst();
|
||||
return Number(result.numDeletedRows);
|
||||
if (pageIds.length > 0) {
|
||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - pages might have been deleted by another node
|
||||
this.logger.warn(
|
||||
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private logCleanupError(pageId: string, error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${pageId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { validate as isValidUUID } from 'uuid';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../page/transclusion/transclusion.service';
|
||||
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
|
||||
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -145,7 +144,7 @@ export class ShareService {
|
||||
|
||||
async getShareForPage(pageId: string, workspaceId: string) {
|
||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||
const traversal = await this.db
|
||||
const share = await this.db
|
||||
.withRecursive('page_hierarchy', (cte) =>
|
||||
cte
|
||||
.selectFrom('pages')
|
||||
@@ -165,67 +164,41 @@ export class ShareService {
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
'shares.createdAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where('ph.isCycle', '=', false),
|
||||
.unionAll(
|
||||
(union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where(sql`ph.level`, '<', sql`25`), // prevent loop
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'parentPageId',
|
||||
'level',
|
||||
'shareId',
|
||||
'shareKey',
|
||||
'includeSubPages',
|
||||
'searchIndexing',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'isCycle',
|
||||
])
|
||||
.execute();
|
||||
|
||||
if (traversal.some((row) => row.isCycle)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedShare = traversal.find((row) => row.shareId !== null);
|
||||
const share = matchedShare
|
||||
? stripPageTraversalMetadata(matchedShare)
|
||||
: undefined;
|
||||
.selectAll()
|
||||
.where('shareId', 'is not', null)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
return undefined;
|
||||
@@ -255,6 +228,67 @@ export class ShareService {
|
||||
};
|
||||
}
|
||||
|
||||
async getShareAncestorPage(
|
||||
ancestorPageId: string,
|
||||
childPageId: string,
|
||||
): Promise<any> {
|
||||
let ancestor = null;
|
||||
try {
|
||||
ancestor = await this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('p.id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
// Continue recursing only when the target ancestor hasn't been found on that branch.
|
||||
.where('pa.found', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll()
|
||||
.where('found', '=', true)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
} catch (err) {
|
||||
// empty
|
||||
}
|
||||
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transclusion content for a public share viewer. Each requested
|
||||
* source page must itself be reachable via the share graph (its own share
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
PageHierarchyCycleError,
|
||||
stripPageTraversalMetadata,
|
||||
} from './page-hierarchy-cycle';
|
||||
|
||||
describe('page hierarchy cycle contract', () => {
|
||||
it('does nothing when no row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[
|
||||
{ id: 'page-1', title: 'Root', isCycle: false },
|
||||
{ id: 'page-2', title: 'Child', isCycle: false },
|
||||
],
|
||||
'page-1',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws PageHierarchyCycleError when a row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'page-1',
|
||||
),
|
||||
).toThrow(PageHierarchyCycleError);
|
||||
});
|
||||
|
||||
it('keeps the root page id on the error for safe logging', () => {
|
||||
try {
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'root-page',
|
||||
);
|
||||
throw new Error('expected a cycle error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PageHierarchyCycleError);
|
||||
expect((error as PageHierarchyCycleError).rootPageId).toBe('root-page');
|
||||
expect((error as PageHierarchyCycleError).code).toBe(
|
||||
'PAGE_HIERARCHY_CYCLE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes traversal metadata without changing the public row fields', () => {
|
||||
const row = { id: 'page-1', title: 'Root', isCycle: false };
|
||||
|
||||
expect(stripPageTraversalMetadata(row)).toEqual({
|
||||
id: 'page-1',
|
||||
title: 'Root',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
export type CycleTrackedRow = {
|
||||
isCycle: boolean;
|
||||
};
|
||||
|
||||
export class PageHierarchyCycleError extends Error {
|
||||
readonly code = 'PAGE_HIERARCHY_CYCLE';
|
||||
|
||||
constructor(readonly rootPageId: string) {
|
||||
super('Cyclic page hierarchy detected');
|
||||
this.name = 'PageHierarchyCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAcyclicPageTraversal<T extends CycleTrackedRow>(
|
||||
rows: readonly T[],
|
||||
rootPageId: string,
|
||||
): void {
|
||||
if (rows.some((row) => row.isCycle)) {
|
||||
throw new PageHierarchyCycleError(rootPageId);
|
||||
}
|
||||
}
|
||||
|
||||
export function stripPageTraversalMetadata<T extends CycleTrackedRow>(
|
||||
row: T,
|
||||
): Omit<T, 'isCycle'> {
|
||||
const { isCycle: _isCycle, ...publicRow } = row;
|
||||
return publicRow;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
import { assertAcyclicPageTraversal } from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -333,7 +332,7 @@ export class PagePermissionRepo {
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const ancestors = await this.db
|
||||
return this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
@@ -341,8 +340,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -353,41 +350,19 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
'pageAccess.id as pageAccessId',
|
||||
'pageAccess.pageId',
|
||||
'pageAccess.accessLevel',
|
||||
'ancestors.depth',
|
||||
'ancestors.isCycle',
|
||||
])
|
||||
.orderBy('ancestors.depth', 'asc')
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, pageId);
|
||||
|
||||
const restrictedAncestor = ancestors.find(
|
||||
(ancestor) => ancestor.pageAccessId !== null,
|
||||
);
|
||||
if (!restrictedAncestor) return undefined;
|
||||
|
||||
return {
|
||||
pageAccessId: restrictedAncestor.pageAccessId,
|
||||
pageId: restrictedAncestor.pageId,
|
||||
accessLevel: restrictedAncestor.accessLevel,
|
||||
depth: restrictedAncestor.depth,
|
||||
};
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,30 +396,17 @@ export class PagePermissionRepo {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
hasHierarchyCycle: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
0 AS depth,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.depth + 1,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT
|
||||
(SELECT bool_or(is_cycle) FROM ancestors) AS "hasHierarchyCycle",
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
@@ -460,13 +422,6 @@ export class PagePermissionRepo {
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (row?.hasHierarchyCycle) {
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: false,
|
||||
};
|
||||
}
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
@@ -506,8 +461,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -518,14 +471,7 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -565,14 +511,6 @@ export class PagePermissionRepo {
|
||||
.else(false)
|
||||
.end()
|
||||
.as('hasInheritedRestriction'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('ancestors')
|
||||
.select('ancestors.ancestorId')
|
||||
.where('ancestors.isCycle', '=', true),
|
||||
)
|
||||
.as('hasHierarchyCycle'),
|
||||
// canAccess: no restricted ancestor without ANY permission
|
||||
eb
|
||||
.case()
|
||||
@@ -700,15 +638,13 @@ export class PagePermissionRepo {
|
||||
|
||||
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
|
||||
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
|
||||
const hasHierarchyCycle = Boolean(result?.hasHierarchyCycle);
|
||||
|
||||
return {
|
||||
hasDirectRestriction,
|
||||
hasInheritedRestriction,
|
||||
hasAnyRestriction:
|
||||
hasDirectRestriction || hasInheritedRestriction || hasHierarchyCycle,
|
||||
canAccess: !hasHierarchyCycle && Boolean(result?.canAccess),
|
||||
canEdit: !hasHierarchyCycle && Boolean(result?.canEdit),
|
||||
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
|
||||
canAccess: Boolean(result?.canAccess),
|
||||
canEdit: Boolean(result?.canEdit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -728,48 +664,7 @@ export class PagePermissionRepo {
|
||||
if (spaceId) {
|
||||
const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
|
||||
if (!hasRestrictions) {
|
||||
const cyclicPages = await this.db
|
||||
.withRecursive('allAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'allAncestors',
|
||||
'allAncestors.parentPageId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('allAncestors')
|
||||
.select('allAncestors.pageId')
|
||||
.distinct()
|
||||
.where('allAncestors.isCycle', '=', true)
|
||||
.execute();
|
||||
const cyclicPageIds = new Set(cyclicPages.map((page) => page.pageId));
|
||||
|
||||
return pageIds.filter((pageId) => !cyclicPageIds.has(pageId));
|
||||
return pageIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,8 +676,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -797,29 +690,12 @@ export class PagePermissionRepo {
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
.select('pages.id')
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -869,8 +745,6 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -886,14 +760,7 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`all_ancestors.depth + 1`.as('depth'),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -954,16 +821,6 @@ export class PagePermissionRepo {
|
||||
.as('canEdit'),
|
||||
)
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
// view filter: no restricted ancestor without any permission
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
@@ -1008,39 +865,21 @@ export class PagePermissionRepo {
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
sql<boolean>`bool_or(ancestors.is_cycle)`.as('hasHierarchyCycle'),
|
||||
sql<boolean>`bool_or(page_access.id IS NOT NULL)`.as('hasPageAccess'),
|
||||
])
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select('pageAccess.id')
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(result?.hasHierarchyCycle || result?.hasPageAccess);
|
||||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1082,8 +921,6 @@ export class PagePermissionRepo {
|
||||
'child.id as childId',
|
||||
'child.id as ancestorId',
|
||||
'child.parentPageId as ancestorParentId',
|
||||
sql<string[]>`ARRAY[child.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
@@ -1099,14 +936,7 @@ export class PagePermissionRepo {
|
||||
'childAncestors.childId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
sql<string[]>`child_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(child_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('childAncestors.isCycle', '=', false),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages as child')
|
||||
@@ -1114,16 +944,6 @@ export class PagePermissionRepo {
|
||||
.distinct()
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('childAncestors')
|
||||
.select('childAncestors.ancestorId')
|
||||
.whereRef('childAncestors.childId', '=', 'child.id')
|
||||
.where('childAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -1158,6 +978,67 @@ export class PagePermissionRepo {
|
||||
return results.map((r) => r.parentPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
|
||||
* Used to filter pages from public shares - if a page is restricted, it and all its
|
||||
* children should be hidden.
|
||||
*/
|
||||
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.withRecursive('descendants', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', rootPageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendants',
|
||||
'descendants.descendantId',
|
||||
'pages.parentPageId',
|
||||
)
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.withRecursive('descendantAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('descendants')
|
||||
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
|
||||
.select([
|
||||
'descendants.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
])
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendantAncestors',
|
||||
'descendantAncestors.ancestorParentId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'descendantAncestors.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('descendantAncestors')
|
||||
.innerJoin(
|
||||
'pageAccess',
|
||||
'pageAccess.pageId',
|
||||
'descendantAncestors.ancestorId',
|
||||
)
|
||||
.select('descendantAncestors.descendantId')
|
||||
.distinct()
|
||||
.execute();
|
||||
|
||||
return results.map((r) => r.descendantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pageId and a set of candidate userIds, return the subset who can
|
||||
* access the page (have permission on ALL restricted ancestors).
|
||||
@@ -1171,27 +1052,17 @@ export class PagePermissionRepo {
|
||||
|
||||
const results = await sql<{ userId: string }>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
SELECT id AS ancestor_id, parent_page_id
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
SELECT p.id, p.parent_page_id
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT cu.user_id AS "userId"
|
||||
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ancestors WHERE is_cycle)
|
||||
AND NOT EXISTS (
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
|
||||
@@ -16,10 +16,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageRepo {
|
||||
@@ -207,31 +203,21 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pd.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
@@ -286,29 +272,19 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.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),
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.select(['id', 'isCycle'])
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, pageId);
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
|
||||
// Restore all pages, but only detach the root page if its parent is deleted
|
||||
@@ -378,12 +354,7 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
) {
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
@@ -394,11 +365,7 @@ export class PageRepo {
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
|
||||
);
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
@@ -524,7 +491,7 @@ export class PageRepo {
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
const pages = await this.db
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -539,8 +506,6 @@ export class PageRepo {
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('id', '=', parentPageId)
|
||||
@@ -559,34 +524,15 @@ export class PageRepo {
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('ph.isCycle', '=', false),
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages.map((page) => stripPageTraversalMetadata(page));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -594,82 +540,69 @@ export class PageRepo {
|
||||
* More efficient than getPageAndDescendants + filtering because:
|
||||
* 1. Single DB query (no separate restricted IDs query)
|
||||
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
|
||||
* 3. Filters the bounded traversal only after hierarchy validation
|
||||
* 3. No in-memory filtering needed
|
||||
*/
|
||||
async getPageAndDescendantsExcludingRestricted(
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
const pages = await this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.where('p.deletedAt', 'is', null)
|
||||
// Only recurse into children of non-restricted pages
|
||||
.where('ph.isRestricted', '=', false)
|
||||
.where('ph.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isRestricted',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages
|
||||
.filter((page) => !page.isRestricted)
|
||||
.map((page) => {
|
||||
const withoutCycleMetadata = stripPageTraversalMetadata(page);
|
||||
const { isRestricted: _isRestricted, ...publicPage } =
|
||||
withoutCycleMetadata;
|
||||
return publicPage;
|
||||
});
|
||||
return (
|
||||
this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.where('p.deletedAt', 'is', null)
|
||||
// Only recurse into children of non-restricted pages
|
||||
.where('ph.isRestricted', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
// Filter out restricted pages from the result
|
||||
.where('isRestricted', '=', false)
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 844f2003cd...cbf35d363a
@@ -1,18 +0,0 @@
|
||||
name: docmost-cycle-test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
environment:
|
||||
POSTGRES_DB: docmost_cycle_test
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- '127.0.0.1:55432:5432'
|
||||
tmpfs:
|
||||
- /var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U docmost -d docmost_cycle_test']
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".*\\.integration-spec\\.ts$",
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"babel-jest",
|
||||
{
|
||||
"presets": [["@babel/preset-env", { "targets": { "node": "current" } }]]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/../src/$1",
|
||||
"^@docmost/base-formula/server$": "<rootDir>/../../../packages/base-formula/src/index.server.ts",
|
||||
"^@docmost/base-formula/client$": "<rootDir>/../../../packages/base-formula/src/index.client.ts"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
import { CamelCasePlugin, Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { DbInterface } from '../../src/database/types/db.interface';
|
||||
|
||||
const disposableDatabaseName = 'docmost_cycle_test';
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('TEST_DATABASE_URL must be set for integration tests');
|
||||
}
|
||||
|
||||
export function assertDisposableTestDatabaseUrl(value: string): void {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(value);
|
||||
} catch {
|
||||
throw new Error('TEST_DATABASE_URL must be a valid PostgreSQL URL');
|
||||
}
|
||||
|
||||
const isPostgresUrl = ['postgres:', 'postgresql:'].includes(
|
||||
parsedUrl.protocol,
|
||||
);
|
||||
const isLoopback = ['127.0.0.1', 'localhost', '[::1]'].includes(
|
||||
parsedUrl.hostname,
|
||||
);
|
||||
const databaseName = decodeURIComponent(parsedUrl.pathname.slice(1));
|
||||
|
||||
if (!isPostgresUrl || !isLoopback || databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Integration tests require the loopback database ${disposableDatabaseName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertDisposableTestDatabaseUrl(databaseUrl);
|
||||
|
||||
const postgresPool = postgres(databaseUrl, { max: 1, onnotice: () => {} });
|
||||
|
||||
export const db = new Kysely<DbInterface>({
|
||||
dialect: new PostgresJSDialect({ postgres: postgresPool }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
|
||||
let databaseSafetyVerified = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await sql<{ databaseName: string }>`
|
||||
SELECT current_database() AS "databaseName"
|
||||
`.execute(db);
|
||||
|
||||
if (result.rows[0]?.databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Connected database must be the disposable ${disposableDatabaseName} database`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseSafetyVerified = true;
|
||||
});
|
||||
|
||||
export async function withStatementTimeout<T>(
|
||||
callback: (connection: Kysely<DbInterface>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return db.connection().execute(async (connection) => {
|
||||
await sql`SET statement_timeout = '500ms'`.execute(connection);
|
||||
|
||||
try {
|
||||
return await callback(connection);
|
||||
} finally {
|
||||
await sql`SET statement_timeout = DEFAULT`.execute(connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function truncateFixtureTables(): Promise<void> {
|
||||
if (!databaseSafetyVerified) {
|
||||
throw new Error('Refusing to truncate an unverified integration database');
|
||||
}
|
||||
|
||||
await sql`TRUNCATE TABLE pages, spaces, workspaces CASCADE`.execute(db);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await truncateFixtureTables();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await postgresPool.end();
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sql } from 'kysely';
|
||||
import { db } from './database';
|
||||
|
||||
export type PageFixture = {
|
||||
id: string;
|
||||
parentPageId: string | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type HierarchyContext = {
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
async function seedWorkspaceAndSpace(): Promise<HierarchyContext> {
|
||||
const workspace = await db
|
||||
.insertInto('workspaces')
|
||||
.values({
|
||||
hostname: `cycle-test-${randomUUID()}`,
|
||||
name: 'Page hierarchy integration workspace',
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const space = await db
|
||||
.insertInto('spaces')
|
||||
.values({
|
||||
name: 'Page hierarchy integration space',
|
||||
slug: `cycle-test-${randomUUID()}`,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { spaceId: space.id, workspaceId: workspace.id };
|
||||
}
|
||||
|
||||
async function insertPage(
|
||||
context: HierarchyContext,
|
||||
title: string,
|
||||
parentPageId: string | null = null,
|
||||
): Promise<PageFixture> {
|
||||
const page = await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
parentPageId,
|
||||
slugId: randomUUID(),
|
||||
spaceId: context.spaceId,
|
||||
title,
|
||||
workspaceId: context.workspaceId,
|
||||
})
|
||||
.returning(['id', 'parentPageId', 'title'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { id: page.id, parentPageId: page.parentPageId, title };
|
||||
}
|
||||
|
||||
export async function seedAcyclicPageChain(): Promise<{
|
||||
root: PageFixture;
|
||||
child: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const child = await insertPage(context, 'Child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', child.id);
|
||||
|
||||
return { root, child, grandchild };
|
||||
}
|
||||
|
||||
export async function seedSelfCycle(): Promise<{ self: PageFixture }> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const self = await insertPage(context, 'Self');
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${self.id} WHERE id = ${self.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return { self: { ...self, parentPageId: self.id } };
|
||||
}
|
||||
|
||||
export async function seedTwoPageCycle(): Promise<{
|
||||
a: PageFixture;
|
||||
b: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const a = await insertPage(context, 'A');
|
||||
const b = await insertPage(context, 'B', a.id);
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${b.id} WHERE id = ${a.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return {
|
||||
a: { ...a, parentPageId: b.id },
|
||||
b,
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedBranchingDescendantTree(): Promise<{
|
||||
root: PageFixture;
|
||||
firstChild: PageFixture;
|
||||
secondChild: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const firstChild = await insertPage(context, 'First child', root.id);
|
||||
const secondChild = await insertPage(context, 'Second child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', firstChild.id);
|
||||
|
||||
return { root, firstChild, secondChild, grandchild };
|
||||
}
|
||||
Reference in New Issue
Block a user