feat: improve workspace index

This commit is contained in:
DarkSky
2026-01-01 01:39:40 +08:00
parent c7b74384a4
commit 91e6f3c45c
4 changed files with 259 additions and 98 deletions

View File

@@ -0,0 +1,11 @@
-- CreateIndex
CREATE INDEX "blobs_workspace_id_status_deleted_at_idx" ON "blobs"("workspace_id", "status", "deleted_at");
-- CreateIndex
CREATE INDEX "workspace_pages_workspace_id_public_idx" ON "workspace_pages"("workspace_id", "public");
-- CreateIndex
CREATE INDEX "workspace_user_permissions_workspace_id_type_status_idx" ON "workspace_user_permissions"("workspace_id", "type", "status");
-- CreateIndex
CREATE INDEX "workspaces_created_at_idx" ON "workspaces"("created_at");

View File

@@ -135,6 +135,7 @@ model Workspace {
commentAttachments CommentAttachment[] commentAttachments CommentAttachment[]
@@index([lastCheckEmbeddings]) @@index([lastCheckEmbeddings])
@@index([createdAt])
@@map("workspaces") @@map("workspaces")
} }
@@ -159,6 +160,7 @@ model WorkspaceDoc {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId]) @@id([workspaceId, docId])
@@index([workspaceId, public])
@@map("workspace_pages") @@map("workspace_pages")
} }
@@ -206,6 +208,7 @@ model WorkspaceUserRole {
@@unique([workspaceId, userId]) @@unique([workspaceId, userId])
// optimize for querying user's workspace permissions // optimize for querying user's workspace permissions
@@index([workspaceId, type, status])
@@index(userId) @@index(userId)
@@map("workspace_user_permissions") @@map("workspace_user_permissions")
} }
@@ -832,6 +835,7 @@ model Blob {
AiWorkspaceBlobEmbedding AiWorkspaceBlobEmbedding[] AiWorkspaceBlobEmbedding AiWorkspaceBlobEmbedding[]
@@id([workspaceId, key]) @@id([workspaceId, key])
@@index([workspaceId, status, deletedAt])
@@map("blobs") @@map("blobs")
} }

View File

@@ -165,6 +165,7 @@ export class AdminWorkspaceResolver {
keyword: filter.keyword, keyword: filter.keyword,
features: filter.features, features: filter.features,
order: this.mapSort(filter.orderBy), order: this.mapSort(filter.orderBy),
includeTotal: false,
}); });
return rows; return rows;
} }
@@ -174,11 +175,9 @@ export class AdminWorkspaceResolver {
@Args('filter', { type: () => ListWorkspaceInput }) @Args('filter', { type: () => ListWorkspaceInput })
filter: ListWorkspaceInput filter: ListWorkspaceInput
) { ) {
const { total } = await this.models.workspace.adminListWorkspaces({ const total = await this.models.workspace.adminCountWorkspaces({
...filter, keyword: filter.keyword,
first: 1, features: filter.features,
skip: 0,
order: this.mapSort(filter.orderBy),
}); });
return total; return total;
} }
@@ -193,6 +192,7 @@ export class AdminWorkspaceResolver {
skip: 0, skip: 0,
keyword: id, keyword: id,
order: 'createdAt', order: 'createdAt',
includeTotal: false,
}); });
const row = rows.find(r => r.id === id); const row = rows.find(r => r.id === id);
if (!row) { if (!row) {
@@ -281,6 +281,7 @@ export class AdminWorkspaceResolver {
skip: 0, skip: 0,
keyword: id, keyword: id,
order: 'createdAt', order: 'createdAt',
includeTotal: false,
}); });
const row = rows.find(r => r.id === id); const row = rows.find(r => r.id === id);
if (!row) { if (!row) {

View File

@@ -27,7 +27,6 @@ type RawWorkspaceSummary = {
ownerName: string | null; ownerName: string | null;
ownerEmail: string | null; ownerEmail: string | null;
ownerAvatarUrl: string | null; ownerAvatarUrl: string | null;
total: bigint | number;
}; };
export type AdminWorkspaceSummary = { export type AdminWorkspaceSummary = {
@@ -187,102 +186,201 @@ export class WorkspaceModel extends BaseModel {
keyword?: string | null; keyword?: string | null;
features?: WorkspaceFeatureName[] | null; features?: WorkspaceFeatureName[] | null;
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'; order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize';
includeTotal?: boolean;
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> { }): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
const keyword = options.keyword?.trim(); const keyword = options.keyword?.trim();
const features = options.features ?? []; const features = options.features ?? [];
const order = this.buildAdminOrder(options.order); const includeTotal = options.includeTotal ?? true;
const total = includeTotal
? await this.adminCountWorkspaces({ keyword, features })
: 0;
if (includeTotal && total === 0) {
return { rows: [], total: 0 };
}
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>` const rows =
WITH feature_set AS ( options.order === 'createdAt' || !options.order
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features ? await this.db.$queryRaw<RawWorkspaceSummary[]>`
FROM workspace_features WITH feature_set AS (
GROUP BY workspace_id SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
), FROM workspace_features
owner AS ( GROUP BY workspace_id
SELECT wur.workspace_id, ),
u.id AS owner_id, owner AS (
u.name AS owner_name, SELECT wur.workspace_id,
u.email AS owner_email, u.id AS owner_id,
u.avatar_url AS owner_avatar_url u.name AS owner_name,
FROM workspace_user_permissions AS wur u.email AS owner_email,
JOIN users u ON wur.user_id = u.id u.avatar_url AS owner_avatar_url
WHERE wur.type = ${WorkspaceRole.Owner} FROM workspace_user_permissions AS wur
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`} JOIN users u ON wur.user_id = u.id
), WHERE wur.type = ${WorkspaceRole.Owner}
snapshot_stats AS ( AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
SELECT workspace_id, ),
SUM(COALESCE(size, octet_length(blob))) AS snapshot_size, filtered AS (
COUNT(*) AS snapshot_count SELECT w.id,
FROM snapshots w.public,
GROUP BY workspace_id w.created_at AS "createdAt",
), w.name,
blob_stats AS ( w.avatar_key AS "avatarKey",
SELECT workspace_id, w.enable_ai AS "enableAi",
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size, w.enable_url_preview AS "enableUrlPreview",
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count w.enable_doc_embedding AS "enableDocEmbedding",
FROM blobs COALESCE(fs.features, ARRAY[]::text[]) AS features,
GROUP BY workspace_id o.owner_id AS "ownerId",
), o.owner_name AS "ownerName",
member_stats AS ( o.owner_email AS "ownerEmail",
SELECT workspace_id, COUNT(*) AS member_count o.owner_avatar_url AS "ownerAvatarUrl"
FROM workspace_user_permissions FROM workspaces w
GROUP BY workspace_id LEFT JOIN feature_set fs ON fs.workspace_id = w.id
), LEFT JOIN owner o ON o.workspace_id = w.id
public_pages AS ( WHERE ${
SELECT workspace_id, COUNT(*) AS public_page_count keyword
FROM workspace_pages ? Prisma.sql`
WHERE public = true (
GROUP BY workspace_id w.id ILIKE ${'%' + keyword + '%'}
) OR o.owner_id ILIKE ${'%' + keyword + '%'}
SELECT w.id, OR o.owner_email ILIKE ${'%' + keyword + '%'}
w.public, )
w.created_at AS "createdAt", `
w.name, : Prisma.sql`TRUE`
w.avatar_key AS "avatarKey", }
w.enable_ai AS "enableAi", AND ${
w.enable_url_preview AS "enableUrlPreview", features.length
w.enable_doc_embedding AS "enableDocEmbedding", ? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}`
COALESCE(ms.member_count, 0) AS "memberCount", : Prisma.sql`TRUE`
COALESCE(pp.public_page_count, 0) AS "publicPageCount", }
COALESCE(ss.snapshot_count, 0) AS "snapshotCount", ORDER BY w.created_at DESC
COALESCE(ss.snapshot_size, 0) AS "snapshotSize", LIMIT ${options.first}
COALESCE(bs.blob_count, 0) AS "blobCount", OFFSET ${options.skip}
COALESCE(bs.blob_size, 0) AS "blobSize", )
COALESCE(fs.features, ARRAY[]::text[]) AS features, SELECT f.*,
o.owner_id AS "ownerId", COALESCE(ms.member_count, 0) AS "memberCount",
o.owner_name AS "ownerName", COALESCE(pp.public_page_count, 0) AS "publicPageCount",
o.owner_email AS "ownerEmail", COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
o.owner_avatar_url AS "ownerAvatarUrl", COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
COUNT(*) OVER() AS total COALESCE(bs.blob_count, 0) AS "blobCount",
FROM workspaces w COALESCE(bs.blob_size, 0) AS "blobSize"
LEFT JOIN feature_set fs ON fs.workspace_id = w.id FROM filtered f
LEFT JOIN owner o ON o.workspace_id = w.id LEFT JOIN LATERAL (
LEFT JOIN snapshot_stats ss ON ss.workspace_id = w.id SELECT COUNT(*) AS member_count
LEFT JOIN blob_stats bs ON bs.workspace_id = w.id FROM workspace_user_permissions
LEFT JOIN member_stats ms ON ms.workspace_id = w.id WHERE workspace_id = f.id
LEFT JOIN public_pages pp ON pp.workspace_id = w.id ) ms ON TRUE
WHERE ${ LEFT JOIN LATERAL (
keyword SELECT COUNT(*) AS public_page_count
? Prisma.sql` FROM workspace_pages
( WHERE workspace_id = f.id AND public = true
w.id ILIKE ${'%' + keyword + '%'} ) pp ON TRUE
OR o.owner_id ILIKE ${'%' + keyword + '%'} LEFT JOIN LATERAL (
OR o.owner_email ILIKE ${'%' + keyword + '%'} SELECT COUNT(*) AS snapshot_count,
) SUM(size) AS snapshot_size
` FROM snapshots
: Prisma.sql`TRUE` WHERE workspace_id = f.id
} ) ss ON TRUE
AND ${ LEFT JOIN LATERAL (
features.length SELECT COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count,
? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}` SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size
: Prisma.sql`TRUE` FROM blobs
} WHERE workspace_id = f.id
ORDER BY ${Prisma.raw(order)} ) bs ON TRUE
LIMIT ${options.first} ORDER BY f."createdAt" DESC
OFFSET ${options.skip} `
`; : await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH feature_set AS (
const total = rows.at(0)?.total ? Number(rows[0].total) : 0; SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
FROM workspace_features
GROUP BY workspace_id
),
owner AS (
SELECT wur.workspace_id,
u.id AS owner_id,
u.name AS owner_name,
u.email AS owner_email,
u.avatar_url AS owner_avatar_url
FROM workspace_user_permissions AS wur
JOIN users u ON wur.user_id = u.id
WHERE wur.type = ${WorkspaceRole.Owner}
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
),
filtered AS (
SELECT w.id,
w.public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_url_preview AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
COALESCE(fs.features, ARRAY[]::text[]) AS features,
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
o.owner_email AS "ownerEmail",
o.owner_avatar_url AS "ownerAvatarUrl"
FROM workspaces w
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
LEFT JOIN owner o ON o.workspace_id = w.id
WHERE ${
keyword
? Prisma.sql`
(
w.id ILIKE ${'%' + keyword + '%'}
OR o.owner_id ILIKE ${'%' + keyword + '%'}
OR o.owner_email ILIKE ${'%' + keyword + '%'}
)
`
: Prisma.sql`TRUE`
}
AND ${
features.length
? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}`
: Prisma.sql`TRUE`
}
),
snapshot_stats AS (
SELECT workspace_id,
SUM(size) AS snapshot_size,
COUNT(*) AS snapshot_count
FROM snapshots
WHERE workspace_id IN (SELECT id FROM filtered)
GROUP BY workspace_id
),
blob_stats AS (
SELECT workspace_id,
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size,
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count
FROM blobs
WHERE workspace_id IN (SELECT id FROM filtered)
GROUP BY workspace_id
),
member_stats AS (
SELECT workspace_id, COUNT(*) AS member_count
FROM workspace_user_permissions
WHERE workspace_id IN (SELECT id FROM filtered)
GROUP BY workspace_id
),
public_pages AS (
SELECT workspace_id, COUNT(*) AS public_page_count
FROM workspace_pages
WHERE public = true AND workspace_id IN (SELECT id FROM filtered)
GROUP BY workspace_id
)
SELECT f.*,
COALESCE(ms.member_count, 0) AS "memberCount",
COALESCE(pp.public_page_count, 0) AS "publicPageCount",
COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
COALESCE(bs.blob_count, 0) AS "blobCount",
COALESCE(bs.blob_size, 0) AS "blobSize"
FROM filtered f
LEFT JOIN snapshot_stats ss ON ss.workspace_id = f.id
LEFT JOIN blob_stats bs ON bs.workspace_id = f.id
LEFT JOIN member_stats ms ON ms.workspace_id = f.id
LEFT JOIN public_pages pp ON pp.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
LIMIT ${options.first}
OFFSET ${options.skip}
`;
const mapped = rows.map(row => ({ const mapped = rows.map(row => ({
id: row.id, id: row.id,
@@ -313,6 +411,53 @@ export class WorkspaceModel extends BaseModel {
return { rows: mapped, total }; return { rows: mapped, total };
} }
async adminCountWorkspaces(options: {
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
}) {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
WITH feature_set AS (
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
FROM workspace_features
GROUP BY workspace_id
),
owner AS (
SELECT wur.workspace_id,
u.id AS owner_id,
u.email AS owner_email
FROM workspace_user_permissions AS wur
JOIN users u ON wur.user_id = u.id
WHERE wur.type = ${WorkspaceRole.Owner}
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
)
SELECT COUNT(*) AS total
FROM workspaces w
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
LEFT JOIN owner o ON o.workspace_id = w.id
WHERE ${
keyword
? Prisma.sql`
(
w.id ILIKE ${'%' + keyword + '%'}
OR o.owner_id ILIKE ${'%' + keyword + '%'}
OR o.owner_email ILIKE ${'%' + keyword + '%'}
)
`
: Prisma.sql`TRUE`
}
AND ${
features.length
? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}`
: Prisma.sql`TRUE`
}
`;
return row?.total ? Number(row.total) : 0;
}
private buildAdminOrder( private buildAdminOrder(
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize' order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'
) { ) {