mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
- CTE approach
- Remove closure table usage
This commit is contained in:
@@ -27,7 +27,6 @@ export class AddPagePermissionDto extends PageIdDto {
|
|||||||
@ArrayMaxSize(25, {
|
@ArrayMaxSize(25, {
|
||||||
message: 'userIds must be an array with no more than 25 elements',
|
message: 'userIds must be an array with no more than 25 elements',
|
||||||
})
|
})
|
||||||
@ArrayMinSize(1)
|
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ export class AddPagePermissionDto extends PageIdDto {
|
|||||||
@ArrayMaxSize(25, {
|
@ArrayMaxSize(25, {
|
||||||
message: 'groupIds must be an array with no more than 25 elements',
|
message: 'groupIds must be an array with no more than 25 elements',
|
||||||
})
|
})
|
||||||
@ArrayMinSize(1)
|
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
groupIds?: string[];
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -47,7 +45,6 @@ export class RemovePagePermissionDto extends PageIdDto {
|
|||||||
@ArrayMaxSize(25, {
|
@ArrayMaxSize(25, {
|
||||||
message: 'userIds must be an array with no more than 25 elements',
|
message: 'userIds must be an array with no more than 25 elements',
|
||||||
})
|
})
|
||||||
@ArrayMinSize(1)
|
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
|
|
||||||
@@ -56,7 +53,6 @@ export class RemovePagePermissionDto extends PageIdDto {
|
|||||||
@ArrayMaxSize(25, {
|
@ArrayMaxSize(25, {
|
||||||
message: 'groupIds must be an array with no more than 25 elements',
|
message: 'groupIds must be an array with no more than 25 elements',
|
||||||
})
|
})
|
||||||
@ArrayMinSize(1)
|
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
groupIds?: string[];
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { PageController } from './page.controller';
|
|||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||||
import { PagePermissionService } from './services/page-permission.service';
|
import { PagePermissionService } from './services/page-permission.service';
|
||||||
import { PageHierarchyService } from './services/page-hierarchy.service';
|
|
||||||
import { PagePermissionController } from './page-permission.controller';
|
import { PagePermissionController } from './page-permission.controller';
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
|
|
||||||
@@ -15,14 +14,8 @@ import { StorageModule } from '../../integrations/storage/storage.module';
|
|||||||
PageHistoryService,
|
PageHistoryService,
|
||||||
TrashCleanupService,
|
TrashCleanupService,
|
||||||
PagePermissionService,
|
PagePermissionService,
|
||||||
PageHierarchyService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
PageService,
|
|
||||||
PageHistoryService,
|
|
||||||
PagePermissionService,
|
|
||||||
PageHierarchyService,
|
|
||||||
],
|
],
|
||||||
|
exports: [PageService, PageHistoryService, PagePermissionService],
|
||||||
imports: [StorageModule],
|
imports: [StorageModule],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { PageHierarchyRepo } from '@docmost/db/repos/page/page-hierarchy.repo';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
type RebuildResult = { rebuilt: boolean; count: number };
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PageHierarchyService {
|
|
||||||
private readonly logger = new Logger(PageHierarchyService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private readonly pageHierarchyRepo: PageHierarchyRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async rebuildAll(): Promise<RebuildResult> {
|
|
||||||
return executeTx(this.db, async (trx) => {
|
|
||||||
const locked = await this.pageHierarchyRepo.tryAcquireGlobalLock(trx);
|
|
||||||
if (!locked) {
|
|
||||||
this.logger.debug('Rebuild all skipped - another process holds the lock');
|
|
||||||
return { rebuilt: false, count: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log('Rebuilding hierarchy for all pages');
|
|
||||||
const count = await this.pageHierarchyRepo.rebuildAll(trx);
|
|
||||||
this.logger.log(`Rebuilt hierarchy for all pages (${count} entries)`);
|
|
||||||
|
|
||||||
return { rebuilt: true, count };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async rebuildBySpace(spaceId: string): Promise<RebuildResult> {
|
|
||||||
return executeTx(this.db, async (trx) => {
|
|
||||||
const locked = await this.pageHierarchyRepo.tryAcquireSpaceLock(
|
|
||||||
spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
if (!locked) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Rebuild for space ${spaceId} skipped - another process holds the lock`,
|
|
||||||
);
|
|
||||||
return { rebuilt: false, count: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Rebuilding hierarchy for space ${spaceId}`);
|
|
||||||
const count = await this.pageHierarchyRepo.rebuildBySpace(spaceId, trx);
|
|
||||||
this.logger.log(
|
|
||||||
`Rebuilt hierarchy for space ${spaceId} (${count} entries)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { rebuilt: true, count };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PageRepo } from './repos/page/page.repo';
|
import { PageRepo } from './repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
||||||
import { PageHierarchyRepo } from './repos/page/page-hierarchy.repo';
|
|
||||||
import { CommentRepo } from './repos/comment/comment.repo';
|
import { CommentRepo } from './repos/comment/comment.repo';
|
||||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||||
@@ -74,7 +73,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageHierarchyRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
@@ -92,7 +90,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PagePermissionRepo,
|
PagePermissionRepo,
|
||||||
PageHierarchyRepo,
|
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('page_hierarchy')
|
|
||||||
.ifNotExists()
|
|
||||||
.addColumn('ancestor_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('pages.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('descendant_id', 'uuid', (col) =>
|
|
||||||
col.notNull().references('pages.id').onDelete('cascade'),
|
|
||||||
)
|
|
||||||
.addColumn('depth', 'integer', (col) => col.notNull().defaultTo(0))
|
|
||||||
.addPrimaryKeyConstraint('page_hierarchy_pkey', [
|
|
||||||
'ancestor_id',
|
|
||||||
'descendant_id',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// indexes
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_hierarchy_descendant')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('page_hierarchy')
|
|
||||||
.column('descendant_id')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_hierarchy_ancestor_depth')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('page_hierarchy')
|
|
||||||
.columns(['ancestor_id', 'depth'])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_page_hierarchy_descendant_depth')
|
|
||||||
.ifNotExists()
|
|
||||||
.on('page_hierarchy')
|
|
||||||
.columns(['descendant_id', 'depth'])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// rebuild function
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION rebuild_page_hierarchy()
|
|
||||||
RETURNS void
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT pg_try_advisory_xact_lock(hashtext('rebuild_page_hierarchy')) THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
TRUNCATE page_hierarchy;
|
|
||||||
|
|
||||||
WITH RECURSIVE page_tree AS (
|
|
||||||
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth
|
|
||||||
FROM pages WHERE deleted_at IS NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT pt.ancestor_id, p.id AS descendant_id, pt.depth + 1
|
|
||||||
FROM page_tree pt
|
|
||||||
JOIN pages p ON p.parent_page_id = pt.descendant_id
|
|
||||||
WHERE p.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
SELECT ancestor_id, descendant_id, depth FROM page_tree;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
// Create insert trigger function
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION page_hierarchy_after_insert()
|
|
||||||
RETURNS TRIGGER
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.deleted_at IS NOT NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NEW.parent_page_id IS NULL THEN
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
VALUES (NEW.id, NEW.id, 0);
|
|
||||||
ELSE
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
SELECT ancestor_id, NEW.id, depth + 1
|
|
||||||
FROM page_hierarchy
|
|
||||||
WHERE descendant_id = NEW.parent_page_id
|
|
||||||
UNION ALL
|
|
||||||
SELECT NEW.id, NEW.id, 0;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE TRIGGER page_hierarchy_after_insert_trigger
|
|
||||||
AFTER INSERT ON pages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION page_hierarchy_after_insert();
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
// Create update trigger function
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE FUNCTION page_hierarchy_after_update()
|
|
||||||
RETURNS TRIGGER
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
subtree_ids UUID[];
|
|
||||||
BEGIN
|
|
||||||
-- Only process if parent_page_id or deleted_at changed
|
|
||||||
IF OLD.parent_page_id IS NOT DISTINCT FROM NEW.parent_page_id
|
|
||||||
AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Handle soft-delete: remove from closure when deleted_at is set
|
|
||||||
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
|
|
||||||
SELECT array_agg(descendant_id) INTO subtree_ids
|
|
||||||
FROM page_hierarchy
|
|
||||||
WHERE ancestor_id = NEW.id;
|
|
||||||
|
|
||||||
DELETE FROM page_hierarchy
|
|
||||||
WHERE descendant_id = ANY(subtree_ids);
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Handle restore: rebuild closure when deleted_at is cleared
|
|
||||||
IF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
|
|
||||||
IF NEW.parent_page_id IS NULL THEN
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
VALUES (NEW.id, NEW.id, 0);
|
|
||||||
ELSE
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
SELECT ancestor_id, NEW.id, depth + 1
|
|
||||||
FROM page_hierarchy
|
|
||||||
WHERE descendant_id = NEW.parent_page_id
|
|
||||||
UNION ALL
|
|
||||||
SELECT NEW.id, NEW.id, 0;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Skip if page is soft-deleted
|
|
||||||
IF NEW.deleted_at IS NOT NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Move operation: parent changed
|
|
||||||
-- Get all descendants of the moved page (including itself)
|
|
||||||
SELECT array_agg(descendant_id) INTO subtree_ids
|
|
||||||
FROM page_hierarchy
|
|
||||||
WHERE ancestor_id = NEW.id;
|
|
||||||
|
|
||||||
-- Delete old ancestor relationships (keep internal subtree links)
|
|
||||||
DELETE FROM page_hierarchy
|
|
||||||
WHERE descendant_id = ANY(subtree_ids)
|
|
||||||
AND NOT (ancestor_id = ANY(subtree_ids));
|
|
||||||
|
|
||||||
-- Insert new ancestor relationships (if new parent exists)
|
|
||||||
IF NEW.parent_page_id IS NOT NULL THEN
|
|
||||||
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
|
|
||||||
SELECT
|
|
||||||
new_anc.ancestor_id,
|
|
||||||
sub.descendant_id,
|
|
||||||
new_anc.depth + sub.depth + 1
|
|
||||||
FROM page_hierarchy new_anc
|
|
||||||
CROSS JOIN page_hierarchy sub
|
|
||||||
WHERE new_anc.descendant_id = NEW.parent_page_id
|
|
||||||
AND sub.ancestor_id = NEW.id
|
|
||||||
AND sub.descendant_id = ANY(subtree_ids);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
CREATE OR REPLACE TRIGGER page_hierarchy_after_update_trigger
|
|
||||||
AFTER UPDATE ON pages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION page_hierarchy_after_update();
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`SELECT rebuild_page_hierarchy()`.execute(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_update_trigger ON pages`.execute(
|
|
||||||
db,
|
|
||||||
);
|
|
||||||
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_insert_trigger ON pages`.execute(
|
|
||||||
db,
|
|
||||||
);
|
|
||||||
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_update()`.execute(db);
|
|
||||||
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_insert()`.execute(db);
|
|
||||||
await sql`DROP FUNCTION IF EXISTS rebuild_page_hierarchy()`.execute(db);
|
|
||||||
await db.schema.dropTable('page_hierarchy').ifExists().execute();
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
|
||||||
import { sql } from 'kysely';
|
|
||||||
import { executeTx } from '../../utils';
|
|
||||||
|
|
||||||
export type IntegrityReport = {
|
|
||||||
healthy: boolean;
|
|
||||||
extraEntries: number;
|
|
||||||
missingEntries: number;
|
|
||||||
affectedSpaceIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepairResult = {
|
|
||||||
rebuiltSpaces: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PageHierarchyRepo {
|
|
||||||
private readonly logger = new Logger(PageHierarchyRepo.name);
|
|
||||||
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async tryAcquireGlobalLock(trx: KyselyTransaction): Promise<boolean> {
|
|
||||||
const result = await sql<{ locked: boolean }>`
|
|
||||||
SELECT pg_try_advisory_xact_lock(hashtext('rebuild_page_hierarchy_global')) as locked
|
|
||||||
`.execute(trx);
|
|
||||||
return result.rows[0]?.locked ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryAcquireSpaceLock(
|
|
||||||
spaceId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const result = await sql<{ locked: boolean }>`
|
|
||||||
SELECT pg_try_advisory_xact_lock(hashtext(${'rebuild_page_hierarchy_space_' + spaceId})) as locked
|
|
||||||
`.execute(trx);
|
|
||||||
return result.rows[0]?.locked ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async rebuildAll(trx: KyselyTransaction): Promise<number> {
|
|
||||||
await trx.deleteFrom('pageHierarchy').execute();
|
|
||||||
|
|
||||||
const result = await trx
|
|
||||||
.withRecursive('pageTree', (qb) =>
|
|
||||||
qb
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id as ancestorId',
|
|
||||||
'id as descendantId',
|
|
||||||
sql<number>`0`.as('depth'),
|
|
||||||
])
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((eb) =>
|
|
||||||
eb
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.innerJoin('pageTree as pt', 'p.parentPageId', 'pt.descendantId')
|
|
||||||
.select([
|
|
||||||
'pt.ancestorId',
|
|
||||||
'p.id as descendantId',
|
|
||||||
sql<number>`pt.depth + 1`.as('depth'),
|
|
||||||
])
|
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.insertInto('pageHierarchy')
|
|
||||||
.columns(['ancestorId', 'descendantId', 'depth'])
|
|
||||||
.expression((eb) =>
|
|
||||||
eb
|
|
||||||
.selectFrom('pageTree')
|
|
||||||
.select(['ancestorId', 'descendantId', 'depth']),
|
|
||||||
)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return Number(result?.numInsertedOrUpdatedRows ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async rebuildBySpace(
|
|
||||||
spaceId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<number> {
|
|
||||||
await trx
|
|
||||||
.deleteFrom('pageHierarchy')
|
|
||||||
.where(
|
|
||||||
'descendantId',
|
|
||||||
'in',
|
|
||||||
trx.selectFrom('pages').select('id').where('spaceId', '=', spaceId),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const result = await trx
|
|
||||||
.withRecursive('pageTree', (qb) =>
|
|
||||||
qb
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id as ancestorId',
|
|
||||||
'id as descendantId',
|
|
||||||
sql<number>`0`.as('depth'),
|
|
||||||
])
|
|
||||||
.where('spaceId', '=', spaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((eb) =>
|
|
||||||
eb
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.innerJoin('pageTree as pt', 'p.parentPageId', 'pt.descendantId')
|
|
||||||
.select([
|
|
||||||
'pt.ancestorId',
|
|
||||||
'p.id as descendantId',
|
|
||||||
sql<number>`pt.depth + 1`.as('depth'),
|
|
||||||
])
|
|
||||||
.where('p.spaceId', '=', spaceId)
|
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.insertInto('pageHierarchy')
|
|
||||||
.columns(['ancestorId', 'descendantId', 'depth'])
|
|
||||||
.expression((eb) =>
|
|
||||||
eb
|
|
||||||
.selectFrom('pageTree')
|
|
||||||
.select(['ancestorId', 'descendantId', 'depth']),
|
|
||||||
)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
return Number(result?.numInsertedOrUpdatedRows ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkIntegrity(): Promise<IntegrityReport> {
|
|
||||||
const result = await this.db
|
|
||||||
.withRecursive('expected', (qb) =>
|
|
||||||
qb
|
|
||||||
.selectFrom('pages')
|
|
||||||
.select([
|
|
||||||
'id as ancestorId',
|
|
||||||
'id as descendantId',
|
|
||||||
sql<number>`0`.as('depth'),
|
|
||||||
'spaceId',
|
|
||||||
])
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.unionAll((eb) =>
|
|
||||||
eb
|
|
||||||
.selectFrom('pages as p')
|
|
||||||
.innerJoin('expected as e', 'p.parentPageId', 'e.descendantId')
|
|
||||||
.select([
|
|
||||||
'e.ancestorId',
|
|
||||||
'p.id as descendantId',
|
|
||||||
sql<number>`e.depth + 1`.as('depth'),
|
|
||||||
'p.spaceId',
|
|
||||||
])
|
|
||||||
.where('p.deletedAt', 'is', null),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.selectFrom('expected as e')
|
|
||||||
.fullJoin('pageHierarchy as ph', (join) =>
|
|
||||||
join
|
|
||||||
.onRef('e.ancestorId', '=', 'ph.ancestorId')
|
|
||||||
.onRef('e.descendantId', '=', 'ph.descendantId'),
|
|
||||||
)
|
|
||||||
.leftJoin('pages as p', 'ph.descendantId', 'p.id')
|
|
||||||
.select([
|
|
||||||
sql<number>`count(*) filter (where e.ancestor_id is null and ph.ancestor_id is not null)`.as(
|
|
||||||
'extraCount',
|
|
||||||
),
|
|
||||||
sql<number>`count(*) filter (where ph.ancestor_id is null and e.ancestor_id is not null)`.as(
|
|
||||||
'missingCount',
|
|
||||||
),
|
|
||||||
sql<
|
|
||||||
string[]
|
|
||||||
>`array_agg(distinct coalesce(e.space_id, p.space_id)) filter (where
|
|
||||||
(e.ancestor_id is null and ph.ancestor_id is not null) or
|
|
||||||
(ph.ancestor_id is null and e.ancestor_id is not null)
|
|
||||||
)`.as('affectedSpaceIds'),
|
|
||||||
])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
const extraCount = Number(result?.extraCount ?? 0);
|
|
||||||
const missingCount = Number(result?.missingCount ?? 0);
|
|
||||||
const affectedSpaceIds = (result?.affectedSpaceIds ?? []).filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
healthy: extraCount === 0 && missingCount === 0,
|
|
||||||
extraEntries: extraCount,
|
|
||||||
missingEntries: missingCount,
|
|
||||||
affectedSpaceIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async repair(): Promise<RepairResult> {
|
|
||||||
const report = await this.checkIntegrity();
|
|
||||||
|
|
||||||
if (report.healthy) {
|
|
||||||
return { rebuiltSpaces: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let rebuiltSpaces = 0;
|
|
||||||
|
|
||||||
for (const spaceId of report.affectedSpaceIds) {
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
const locked = await this.tryAcquireSpaceLock(spaceId, trx);
|
|
||||||
if (!locked) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Repair for space ${spaceId} skipped - another process holds the lock`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.rebuildBySpace(spaceId, trx);
|
|
||||||
rebuiltSpaces++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rebuiltSpaces };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { sql, SqlBool } from 'kysely';
|
import { ExpressionBuilder, sql, SqlBool } from 'kysely';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { DB } from '@docmost/db/types/db';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PagePermissionRepo {
|
export class PagePermissionRepo {
|
||||||
@@ -300,15 +301,34 @@ export class PagePermissionRepo {
|
|||||||
{ pageId: string; accessLevel: string; depth: number } | undefined
|
{ pageId: string; accessLevel: string; depth: number } | undefined
|
||||||
> {
|
> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('pageHierarchy')
|
.withRecursive('ancestors', (qb) =>
|
||||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId',
|
||||||
|
sql<number>`0`.as('depth'),
|
||||||
|
])
|
||||||
|
.where('pages.id', '=', pageId)
|
||||||
|
.unionAll((eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||||
|
.select([
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId',
|
||||||
|
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestors')
|
||||||
|
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||||
.select([
|
.select([
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageAccess.accessLevel',
|
'pageAccess.accessLevel',
|
||||||
'pageHierarchy.depth',
|
'ancestors.depth',
|
||||||
])
|
])
|
||||||
.where('pageHierarchy.descendantId', '=', pageId)
|
.orderBy('ancestors.depth', 'asc')
|
||||||
.orderBy('pageHierarchy.depth', 'asc')
|
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,8 +337,20 @@ export class PagePermissionRepo {
|
|||||||
*/
|
*/
|
||||||
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
||||||
const deniedAncestor = await this.db
|
const deniedAncestor = await this.db
|
||||||
.selectFrom('pageHierarchy')
|
.withRecursive('ancestors', (qb) =>
|
||||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.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']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestors')
|
||||||
|
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
||||||
@@ -328,16 +360,12 @@ export class PagePermissionRepo {
|
|||||||
eb(
|
eb(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb
|
this.userGroupIdsSubquery(eb, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.where('pageHierarchy.descendantId', '=', pageId)
|
|
||||||
.where('pagePermissions.id', 'is', null)
|
.where('pagePermissions.id', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -349,8 +377,20 @@ export class PagePermissionRepo {
|
|||||||
*/
|
*/
|
||||||
async canUserEditPage(userId: string, pageId: string): Promise<boolean> {
|
async canUserEditPage(userId: string, pageId: string): Promise<boolean> {
|
||||||
const deniedAncestor = await this.db
|
const deniedAncestor = await this.db
|
||||||
.selectFrom('pageHierarchy')
|
.withRecursive('ancestors', (qb) =>
|
||||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.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']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestors')
|
||||||
|
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
||||||
@@ -361,16 +401,12 @@ export class PagePermissionRepo {
|
|||||||
eb(
|
eb(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb
|
this.userGroupIdsSubquery(eb, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.where('pageHierarchy.descendantId', '=', pageId)
|
|
||||||
.where('pagePermissions.id', 'is', null)
|
.where('pagePermissions.id', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
@@ -397,6 +433,26 @@ export class PagePermissionRepo {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
}> {
|
}> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
|
.withRecursive('ancestors', (qb) =>
|
||||||
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId',
|
||||||
|
sql<number>`0`.as('depth'),
|
||||||
|
])
|
||||||
|
.where('pages.id', '=', pageId)
|
||||||
|
.unionAll((eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||||
|
.select([
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId',
|
||||||
|
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
// hasDirectRestriction: this page itself has page_access entry
|
// hasDirectRestriction: this page itself has page_access entry
|
||||||
@@ -420,15 +476,14 @@ export class PagePermissionRepo {
|
|||||||
.when(
|
.when(
|
||||||
eb.exists(
|
eb.exists(
|
||||||
eb
|
eb
|
||||||
.selectFrom('pageHierarchy')
|
.selectFrom('ancestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'ancestors.ancestorId',
|
||||||
)
|
)
|
||||||
.select('pageAccess.id')
|
.select('pageAccess.id')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
|
.where('ancestors.depth', '>', 0),
|
||||||
.where('pageHierarchy.depth', '>', 0),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then(true)
|
.then(true)
|
||||||
@@ -442,11 +497,11 @@ export class PagePermissionRepo {
|
|||||||
eb.not(
|
eb.not(
|
||||||
eb.exists(
|
eb.exists(
|
||||||
eb
|
eb
|
||||||
.selectFrom('pageHierarchy')
|
.selectFrom('ancestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'ancestors.ancestorId',
|
||||||
)
|
)
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
@@ -461,16 +516,12 @@ export class PagePermissionRepo {
|
|||||||
eb2(
|
eb2(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb2
|
this.userGroupIdsSubquery(eb2, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
|
|
||||||
.where('pagePermissions.id', 'is', null),
|
.where('pagePermissions.id', 'is', null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -486,11 +537,11 @@ export class PagePermissionRepo {
|
|||||||
eb.not(
|
eb.not(
|
||||||
eb.exists(
|
eb.exists(
|
||||||
eb
|
eb
|
||||||
.selectFrom('pageHierarchy')
|
.selectFrom('ancestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'ancestors.ancestorId',
|
||||||
)
|
)
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
@@ -506,16 +557,12 @@ export class PagePermissionRepo {
|
|||||||
eb2(
|
eb2(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb2
|
this.userGroupIdsSubquery(eb2, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
|
|
||||||
.where('pagePermissions.id', 'is', null),
|
.where('pagePermissions.id', 'is', null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -552,6 +599,30 @@ export class PagePermissionRepo {
|
|||||||
if (pageIds.length === 0) return [];
|
if (pageIds.length === 0) return [];
|
||||||
|
|
||||||
const results = await this.db
|
const results = await this.db
|
||||||
|
.withRecursive('allAncestors', (qb) =>
|
||||||
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as pageId',
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId',
|
||||||
|
])
|
||||||
|
.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',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select('pages.id')
|
.select('pages.id')
|
||||||
// Check if user lacks writer permission on any restricted ancestor
|
// Check if user lacks writer permission on any restricted ancestor
|
||||||
@@ -562,11 +633,11 @@ export class PagePermissionRepo {
|
|||||||
eb.not(
|
eb.not(
|
||||||
eb.exists(
|
eb.exists(
|
||||||
eb
|
eb
|
||||||
.selectFrom('pageHierarchy')
|
.selectFrom('allAncestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'allAncestors.ancestorId',
|
||||||
)
|
)
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
@@ -582,16 +653,13 @@ export class PagePermissionRepo {
|
|||||||
eb2(
|
eb2(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb2
|
this.userGroupIdsSubquery(eb2, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
|
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||||
.where('pagePermissions.id', 'is', null),
|
.where('pagePermissions.id', 'is', null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -606,11 +674,11 @@ export class PagePermissionRepo {
|
|||||||
.where(({ not, exists, selectFrom }) =>
|
.where(({ not, exists, selectFrom }) =>
|
||||||
not(
|
not(
|
||||||
exists(
|
exists(
|
||||||
selectFrom('pageHierarchy')
|
selectFrom('allAncestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'allAncestors.ancestorId',
|
||||||
)
|
)
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
@@ -621,16 +689,13 @@ export class PagePermissionRepo {
|
|||||||
eb(
|
eb(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb
|
this.userGroupIdsSubquery(eb, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
|
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||||
.where('pagePermissions.id', 'is', null),
|
.where('pagePermissions.id', 'is', null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -646,10 +711,21 @@ export class PagePermissionRepo {
|
|||||||
*/
|
*/
|
||||||
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
|
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('pageHierarchy')
|
.withRecursive('ancestors', (qb) =>
|
||||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
|
qb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.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']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestors')
|
||||||
|
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||||
.select('pageAccess.id')
|
.select('pageAccess.id')
|
||||||
.where('pageHierarchy.descendantId', '=', pageId)
|
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return !!result;
|
return !!result;
|
||||||
@@ -688,6 +764,31 @@ export class PagePermissionRepo {
|
|||||||
if (parentIds.length === 0) return [];
|
if (parentIds.length === 0) return [];
|
||||||
|
|
||||||
const results = await this.db
|
const results = await this.db
|
||||||
|
.withRecursive('childAncestors', (qb) =>
|
||||||
|
qb
|
||||||
|
.selectFrom('pages as child')
|
||||||
|
.select([
|
||||||
|
'child.id as childId',
|
||||||
|
'child.id as ancestorId',
|
||||||
|
'child.parentPageId as ancestorParentId',
|
||||||
|
])
|
||||||
|
.where('child.parentPageId', 'in', parentIds)
|
||||||
|
.where('child.deletedAt', 'is', null)
|
||||||
|
.unionAll((eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin(
|
||||||
|
'childAncestors',
|
||||||
|
'childAncestors.ancestorParentId',
|
||||||
|
'pages.id',
|
||||||
|
)
|
||||||
|
.select([
|
||||||
|
'childAncestors.childId',
|
||||||
|
'pages.id as ancestorId',
|
||||||
|
'pages.parentPageId as ancestorParentId',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
.selectFrom('pages as child')
|
.selectFrom('pages as child')
|
||||||
.select('child.parentPageId')
|
.select('child.parentPageId')
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -696,11 +797,11 @@ export class PagePermissionRepo {
|
|||||||
.where(({ not, exists, selectFrom }) =>
|
.where(({ not, exists, selectFrom }) =>
|
||||||
not(
|
not(
|
||||||
exists(
|
exists(
|
||||||
selectFrom('pageHierarchy')
|
selectFrom('childAncestors')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'pageAccess',
|
'pageAccess',
|
||||||
'pageAccess.pageId',
|
'pageAccess.pageId',
|
||||||
'pageHierarchy.ancestorId',
|
'childAncestors.ancestorId',
|
||||||
)
|
)
|
||||||
.leftJoin('pagePermissions', (join) =>
|
.leftJoin('pagePermissions', (join) =>
|
||||||
join
|
join
|
||||||
@@ -711,16 +812,13 @@ export class PagePermissionRepo {
|
|||||||
eb(
|
eb(
|
||||||
'pagePermissions.groupId',
|
'pagePermissions.groupId',
|
||||||
'in',
|
'in',
|
||||||
eb
|
this.userGroupIdsSubquery(eb, userId),
|
||||||
.selectFrom('groupUsers')
|
|
||||||
.select('groupUsers.groupId')
|
|
||||||
.where('groupUsers.userId', '=', userId),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('pageAccess.pageId')
|
.select('pageAccess.pageId')
|
||||||
.whereRef('pageHierarchy.descendantId', '=', 'child.id')
|
.whereRef('childAncestors.childId', '=', 'child.id')
|
||||||
.where('pagePermissions.id', 'is', null),
|
.where('pagePermissions.id', 'is', null),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -737,27 +835,67 @@ export class PagePermissionRepo {
|
|||||||
*/
|
*/
|
||||||
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
||||||
const results = await this.db
|
const results = await this.db
|
||||||
.selectFrom('pageHierarchy as subtree')
|
.withRecursive('descendants', (qb) =>
|
||||||
.where('subtree.ancestorId', '=', rootPageId)
|
qb
|
||||||
.innerJoin(
|
.selectFrom('pages')
|
||||||
(eb) =>
|
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||||
eb
|
.where('pages.id', '=', rootPageId)
|
||||||
.selectFrom('pageHierarchy as inner')
|
.unionAll((eb) =>
|
||||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'inner.ancestorId')
|
eb
|
||||||
.select('inner.descendantId as restrictedDescendant')
|
.selectFrom('pages')
|
||||||
.distinct()
|
.innerJoin(
|
||||||
.as('restricted'),
|
'descendants',
|
||||||
(join) =>
|
'descendants.descendantId',
|
||||||
join.onRef(
|
'pages.parentPageId',
|
||||||
'restricted.restrictedDescendant',
|
)
|
||||||
'=',
|
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||||
'subtree.descendantId',
|
.where('pages.deletedAt', 'is', null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.select('subtree.descendantId')
|
.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()
|
.distinct()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return results.map((r) => r.descendantId);
|
return results.map((r) => r.descendantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private userGroupIdsSubquery(
|
||||||
|
eb: ExpressionBuilder<any, keyof DB>,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
return eb
|
||||||
|
.selectFrom('groupUsers')
|
||||||
|
.select('groupUsers.groupId')
|
||||||
|
.where('groupUsers.userId', '=', userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export enum QueueName {
|
|||||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||||
SEARCH_QUEUE = '{search-queue}',
|
SEARCH_QUEUE = '{search-queue}',
|
||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HIERARCHY_QUEUE = '{hierarchy-queue}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
@@ -59,8 +58,4 @@ export enum QueueJob {
|
|||||||
|
|
||||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||||
|
|
||||||
REBUILD_HIERARCHY_ALL = 'rebuild-hierarchy-all',
|
|
||||||
REBUILD_HIERARCHY_ALL_BY_SPACE = 'rebuild-hierarchy-all-by-space',
|
|
||||||
REBUILD_HIERARCHY_SPACE = 'rebuild-hierarchy-space',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
|
||||||
|
|
||||||
|
|
||||||
export interface IPageBacklinkJob {
|
export interface IPageBacklinkJob {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -9,8 +8,4 @@ export interface IPageBacklinkJob {
|
|||||||
|
|
||||||
export interface IStripeSeatsSyncJob {
|
export interface IStripeSeatsSyncJob {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface IRebuildHierarchyJob {
|
|
||||||
spaceId?: string;
|
|
||||||
}
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { QueueJob, QueueName } from '../constants';
|
|
||||||
import { IRebuildHierarchyJob } from '../constants/queue.interface';
|
|
||||||
import { PageHierarchyRepo } from '@docmost/db/repos/page/page-hierarchy.repo';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Processor(QueueName.HIERARCHY_QUEUE)
|
|
||||||
export class HierarchyProcessor extends WorkerHost implements OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(HierarchyProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
private readonly pageHierarchyRepo: PageHierarchyRepo,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<IRebuildHierarchyJob, void>): Promise<void> {
|
|
||||||
try {
|
|
||||||
switch (job.name) {
|
|
||||||
case QueueJob.REBUILD_HIERARCHY_ALL:
|
|
||||||
await this.rebuildAll();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QueueJob.REBUILD_HIERARCHY_ALL_BY_SPACE:
|
|
||||||
await this.rebuildAllBySpace();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case QueueJob.REBUILD_HIERARCHY_SPACE:
|
|
||||||
if (!job.data.spaceId) {
|
|
||||||
throw new Error('spaceId is required for space rebuild');
|
|
||||||
}
|
|
||||||
await this.rebuildBySpace(job.data.spaceId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async rebuildAll(): Promise<void> {
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
const locked = await this.pageHierarchyRepo.tryAcquireGlobalLock(trx);
|
|
||||||
if (!locked) {
|
|
||||||
this.logger.debug(
|
|
||||||
'Rebuild all skipped - another process holds the lock',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug('Rebuilding hierarchy for all pages');
|
|
||||||
const count = await this.pageHierarchyRepo.rebuildAll(trx);
|
|
||||||
this.logger.debug(`Rebuilt hierarchy for all pages (${count} entries)`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async rebuildBySpace(spaceId: string): Promise<void> {
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
const locked = await this.pageHierarchyRepo.tryAcquireSpaceLock(
|
|
||||||
spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
if (!locked) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Rebuild for space ${spaceId} skipped - another process holds the lock`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Rebuilding hierarchy for space ${spaceId}`);
|
|
||||||
const count = await this.pageHierarchyRepo.rebuildBySpace(spaceId, trx);
|
|
||||||
this.logger.debug(
|
|
||||||
`Rebuilt hierarchy for space ${spaceId} (${count} entries)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async rebuildAllBySpace(): Promise<void> {
|
|
||||||
let lastId: string | null = null;
|
|
||||||
const BATCH_SIZE = 100;
|
|
||||||
let totalSpaces = 0;
|
|
||||||
|
|
||||||
this.logger.debug('Starting hierarchy rebuild for all spaces');
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const spaces = await this.db
|
|
||||||
.selectFrom('spaces')
|
|
||||||
.select('id')
|
|
||||||
.$if(Boolean(lastId), (qb) => qb.where('id', '>', lastId!))
|
|
||||||
.orderBy('id', 'asc')
|
|
||||||
.limit(BATCH_SIZE)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (spaces.length === 0) break;
|
|
||||||
|
|
||||||
for (const space of spaces) {
|
|
||||||
await this.rebuildBySpace(space.id);
|
|
||||||
totalSpaces++;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastId = spaces[spaces.length - 1].id;
|
|
||||||
this.logger.debug(`Rebuilt hierarchy for ${totalSpaces} spaces...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Completed hierarchy rebuild for ${totalSpaces} spaces`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('active')
|
|
||||||
onActive(job: Job) {
|
|
||||||
this.logger.debug(`Processing ${job.name} job`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
|
||||||
onError(job: Job) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
|
||||||
onCompleted(job: Job) {
|
|
||||||
this.logger.debug(`Completed ${job.name} job`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
if (this.worker) {
|
|
||||||
await this.worker.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { EnvironmentService } from '../environment/environment.service';
|
|||||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
||||||
import { QueueName } from './constants';
|
import { QueueName } from './constants';
|
||||||
import { BacklinksProcessor } from './processors/backlinks.processor';
|
import { BacklinksProcessor } from './processors/backlinks.processor';
|
||||||
import { HierarchyProcessor } from './processors/hierarchy.processor';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -74,22 +73,8 @@ import { HierarchyProcessor } from './processors/hierarchy.processor';
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.HIERARCHY_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: {
|
|
||||||
count: 50,
|
|
||||||
},
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: 'exponential',
|
|
||||||
delay: 20 * 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [BacklinksProcessor, HierarchyProcessor],
|
providers: [BacklinksProcessor],
|
||||||
})
|
})
|
||||||
export class QueueModule {}
|
export class QueueModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user