Compare commits

..
Author SHA1 Message Date
Philipinho 531d2151a1 fix: destroy collab room providers synchronously on unmount
The react provider wrapper defers provider.destroy() by a timeout on
unmount, so a room for the same document mounting in the same commit
attaches while the old provider is still registered on the shared
socket and crashes with 'Cannot attach two providers with the same
effective name'. A local CollabRoom replaces it: synchronous destroy
keeps unmount-before-mount ordering, and attach evicts any provider a
previously interrupted teardown leaked so the crash cannot wedge a
document until reload.
2026-09-02 01:05:48 +01:00
Philipinho 5b85464561 sync 2026-08-28 00:07:10 +01:00
16 changed files with 401 additions and 2198 deletions
@@ -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")} />
-1
View File
@@ -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,
);
}
}
+91 -57
View File
@@ -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
+72 -139
View File
@@ -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,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
-26
View File
@@ -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
-90
View File
@@ -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 };
}