mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 17:46:18 +08:00
feat(server): add cloud indexer with Elasticsearch and Manticoresearch providers (#11835)
close CLOUD-137 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced advanced workspace-scoped search and aggregation capabilities with support for complex queries, highlights, and pagination. - Added pluggable search providers: Elasticsearch and Manticoresearch. - New GraphQL queries, schema types, and resolver support for search and aggregation. - Enhanced configuration options for search providers in self-hosted and cloud deployments. - Added Docker Compose services and environment variables for Elasticsearch and Manticoresearch. - Integrated indexer service into deployment and CI workflows. - **Bug Fixes** - Improved error handling with new user-friendly error messages for search provider and indexer issues. - **Documentation** - Updated configuration examples and environment variable references for indexer and search providers. - **Tests** - Added extensive end-to-end and provider-specific tests covering indexing, searching, aggregation, deletion, and error cases. - Included snapshot tests and test fixtures for search providers. - **Chores** - Updated deployment scripts, Helm charts, and Kubernetes manifests to include indexer-related environment variables and secrets. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { AggregateBucket } from './providers';
|
||||
import { IndexerService, SearchNodeWithMeta } from './service';
|
||||
import {
|
||||
AggregateInput,
|
||||
AggregateResultObjectType,
|
||||
SearchInput,
|
||||
SearchQueryOccur,
|
||||
SearchQueryType,
|
||||
SearchResultObjectType,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class IndexerResolver {
|
||||
constructor(
|
||||
private readonly indexer: IndexerService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
@ResolveField(() => SearchResultObjectType, {
|
||||
description: 'Search a specific table',
|
||||
})
|
||||
async search(
|
||||
@CurrentUser() me: UserType,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('input') input: SearchInput
|
||||
): Promise<SearchResultObjectType> {
|
||||
// currentUser can read the workspace
|
||||
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
|
||||
this.#addWorkspaceFilter(workspace, input);
|
||||
|
||||
const result = await this.indexer.search(input);
|
||||
const nodes = await this.#filterUserReadableDocs(
|
||||
workspace,
|
||||
me,
|
||||
result.nodes
|
||||
);
|
||||
return {
|
||||
nodes,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
hasMore: nodes.length > 0,
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => AggregateResultObjectType, {
|
||||
description: 'Search a specific table with aggregate',
|
||||
})
|
||||
async aggregate(
|
||||
@CurrentUser() me: UserType,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('input') input: AggregateInput
|
||||
): Promise<AggregateResultObjectType> {
|
||||
// currentUser can read the workspace
|
||||
await this.ac.user(me.id).workspace(workspace.id).assert('Workspace.Read');
|
||||
this.#addWorkspaceFilter(workspace, input);
|
||||
|
||||
const result = await this.indexer.aggregate(input);
|
||||
const needs: AggregateBucket[] = [];
|
||||
for (const bucket of result.buckets) {
|
||||
bucket.hits.nodes = await this.#filterUserReadableDocs(
|
||||
workspace,
|
||||
me,
|
||||
bucket.hits.nodes as SearchNodeWithMeta[]
|
||||
);
|
||||
if (bucket.hits.nodes.length > 0) {
|
||||
needs.push(bucket);
|
||||
}
|
||||
}
|
||||
return {
|
||||
buckets: needs,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
hasMore: needs.length > 0,
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#addWorkspaceFilter(
|
||||
workspace: WorkspaceType,
|
||||
input: SearchInput | AggregateInput
|
||||
) {
|
||||
// filter by workspace id
|
||||
input.query = {
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.must,
|
||||
queries: [
|
||||
{
|
||||
type: SearchQueryType.match,
|
||||
field: 'workspaceId',
|
||||
match: workspace.id,
|
||||
},
|
||||
input.query,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* filter user readable docs on team workspace
|
||||
*/
|
||||
async #filterUserReadableDocs(
|
||||
workspace: WorkspaceType,
|
||||
user: UserType,
|
||||
nodes: SearchNodeWithMeta[]
|
||||
) {
|
||||
const isTeamWorkspace = await this.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
if (!isTeamWorkspace) {
|
||||
return nodes;
|
||||
}
|
||||
const needs: SearchNodeWithMeta[] = [];
|
||||
// TODO(@fengmk2): CLOUD-208 support batch check
|
||||
for (const node of nodes) {
|
||||
const canRead = await this.ac
|
||||
.user(user.id)
|
||||
.doc(node._source.workspaceId, node._source.docId)
|
||||
.can('Doc.Read');
|
||||
if (canRead) {
|
||||
needs.push(node);
|
||||
}
|
||||
}
|
||||
return needs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user