diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md
new file mode 100644
index 0000000000..74468b7106
--- /dev/null
+++ b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md
@@ -0,0 +1,51 @@
+# Snapshot report for `src/__tests__/e2e/indexer/search-docs.spec.ts`
+
+The actual snapshot is saved in `search-docs.spec.ts.snap`.
+
+Generated by [AVA](https://avajs.dev).
+
+## should search docs by keyword
+
+> Snapshot 1
+
+ [
+ {
+ blockId: 'block-0',
+ createdAt: '2025-04-22T00:00:00.000Z',
+ docId: 'doc-0',
+ highlight: 'test1 hello',
+ title: '',
+ updatedAt: '2025-04-22T00:00:00.000Z',
+ },
+ {
+ blockId: 'block-2',
+ createdAt: '2025-03-22T00:00:00.000Z',
+ docId: 'doc-2',
+ highlight: 'test3 hello',
+ title: '',
+ updatedAt: '2025-03-22T03:00:01.000Z',
+ },
+ {
+ blockId: 'block-1',
+ createdAt: '2021-04-22T00:00:00.000Z',
+ docId: 'doc-1',
+ highlight: 'test2 hello',
+ title: '',
+ updatedAt: '2021-04-22T00:00:00.000Z',
+ },
+ ]
+
+## should search docs by keyword with limit 1
+
+> Snapshot 1
+
+ [
+ {
+ blockId: 'block-0',
+ createdAt: '2025-04-22T00:00:00.000Z',
+ docId: 'doc-0',
+ highlight: 'test1 hello',
+ title: '',
+ updatedAt: '2025-04-22T00:00:00.000Z',
+ },
+ ]
diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap
new file mode 100644
index 0000000000..219047b992
Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap differ
diff --git a/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts b/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts
new file mode 100644
index 0000000000..95fc5e1bbb
--- /dev/null
+++ b/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts
@@ -0,0 +1,182 @@
+import { indexerSearchDocsQuery, SearchTable } from '@affine/graphql';
+import { omit } from 'lodash-es';
+
+import { IndexerService } from '../../../plugins/indexer/service';
+import { Mockers } from '../../mocks';
+import { app, e2e } from '../test';
+
+e2e('should search docs by keyword', async t => {
+ const owner = await app.signup();
+
+ const workspace = await app.create(Mockers.Workspace, {
+ owner,
+ });
+
+ const indexerService = app.get(IndexerService);
+
+ await indexerService.write(
+ SearchTable.block,
+ [
+ {
+ docId: 'doc-0',
+ workspaceId: workspace.id,
+ content: 'test1 hello',
+ flavour: 'markdown',
+ blockId: 'block-0',
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2025-04-22T00:00:00.000Z'),
+ updatedAt: new Date('2025-04-22T00:00:00.000Z'),
+ },
+ {
+ docId: 'doc-1',
+ workspaceId: workspace.id,
+ content: 'test2 hello',
+ flavour: 'markdown',
+ blockId: 'block-1',
+ refDocId: ['doc-0'],
+ ref: ['{"foo": "bar1"}'],
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2021-04-22T00:00:00.000Z'),
+ updatedAt: new Date('2021-04-22T00:00:00.000Z'),
+ },
+ {
+ docId: 'doc-2',
+ workspaceId: workspace.id,
+ content: 'test3 hello',
+ flavour: 'markdown',
+ blockId: 'block-2',
+ refDocId: ['doc-0', 'doc-2'],
+ ref: ['{"foo": "bar1"}', '{"foo": "bar3"}'],
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2025-03-22T00:00:00.000Z'),
+ updatedAt: new Date('2025-03-22T03:00:01.000Z'),
+ },
+ ],
+ {
+ refresh: true,
+ }
+ );
+
+ const result = await app.gql({
+ query: indexerSearchDocsQuery,
+ variables: {
+ id: workspace.id,
+ input: {
+ keyword: 'hello',
+ },
+ },
+ });
+
+ t.is(result.workspace.searchDocs.length, 3);
+ t.snapshot(
+ result.workspace.searchDocs.map(doc =>
+ omit(doc, 'createdByUser', 'updatedByUser')
+ )
+ );
+});
+
+e2e('should search docs by keyword with limit 1', async t => {
+ const owner = await app.signup();
+
+ const workspace = await app.create(Mockers.Workspace, {
+ owner,
+ });
+
+ const indexerService = app.get(IndexerService);
+
+ await indexerService.write(
+ SearchTable.block,
+ [
+ {
+ docId: 'doc-0',
+ workspaceId: workspace.id,
+ content: 'test1 hello',
+ flavour: 'markdown',
+ blockId: 'block-0',
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2025-04-22T00:00:00.000Z'),
+ updatedAt: new Date('2025-04-22T00:00:00.000Z'),
+ },
+ {
+ docId: 'doc-1',
+ workspaceId: workspace.id,
+ content: 'test2 hello',
+ flavour: 'markdown',
+ blockId: 'block-1',
+ refDocId: ['doc-0'],
+ ref: ['{"foo": "bar1"}'],
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2021-04-22T00:00:00.000Z'),
+ updatedAt: new Date('2021-04-22T00:00:00.000Z'),
+ },
+ {
+ docId: 'doc-2',
+ workspaceId: workspace.id,
+ content: 'test3 hello',
+ flavour: 'markdown',
+ blockId: 'block-2',
+ refDocId: ['doc-0', 'doc-2'],
+ ref: ['{"foo": "bar1"}', '{"foo": "bar3"}'],
+ createdByUserId: owner.id,
+ updatedByUserId: owner.id,
+ createdAt: new Date('2025-03-22T00:00:00.000Z'),
+ updatedAt: new Date('2025-03-22T03:00:01.000Z'),
+ },
+ ],
+ {
+ refresh: true,
+ }
+ );
+
+ const result = await app.gql({
+ query: indexerSearchDocsQuery,
+ variables: {
+ id: workspace.id,
+ input: {
+ keyword: 'hello',
+ limit: 1,
+ },
+ },
+ });
+
+ t.is(result.workspace.searchDocs.length, 1);
+ t.snapshot(
+ result.workspace.searchDocs.map(doc =>
+ omit(doc, 'createdByUser', 'updatedByUser')
+ )
+ );
+});
+
+e2e(
+ 'should search docs by keyword failed when workspace is no permission',
+ async t => {
+ const owner = await app.signup();
+
+ const workspace = await app.create(Mockers.Workspace, {
+ owner,
+ });
+
+ // signup another user
+ await app.signup();
+
+ await t.throwsAsync(
+ app.gql({
+ query: indexerSearchDocsQuery,
+ variables: {
+ id: workspace.id,
+ input: {
+ keyword: 'hello',
+ },
+ },
+ }),
+ {
+ message: /You do not have permission to access Space/,
+ }
+ );
+ }
+);
diff --git a/packages/backend/server/src/plugins/indexer/resolver.ts b/packages/backend/server/src/plugins/indexer/resolver.ts
index e4e988f185..4b8c6acd9a 100644
--- a/packages/backend/server/src/plugins/indexer/resolver.ts
+++ b/packages/backend/server/src/plugins/indexer/resolver.ts
@@ -10,6 +10,8 @@ import { IndexerService, SearchNodeWithMeta } from './service';
import {
AggregateInput,
AggregateResultObjectType,
+ SearchDocObjectType,
+ SearchDocsInput,
SearchInput,
SearchQueryOccur,
SearchQueryType,
@@ -86,6 +88,29 @@ export class IndexerResolver {
};
}
+ @ResolveField(() => [SearchDocObjectType], {
+ description: 'Search docs by keyword',
+ })
+ async searchDocs(
+ @CurrentUser() me: UserType,
+ @Parent() workspace: WorkspaceType,
+ @Args('input') input: SearchDocsInput
+ ): Promise {
+ const docs = await this.indexer.searchDocsByKeyword(
+ workspace.id,
+ input.keyword,
+ {
+ limit: input.limit,
+ }
+ );
+
+ const needs = await this.ac
+ .user(me.id)
+ .workspace(workspace.id)
+ .docs(docs, 'Doc.Read');
+ return needs;
+ }
+
#addWorkspaceFilter(
workspace: WorkspaceType,
input: SearchInput | AggregateInput
diff --git a/packages/backend/server/src/plugins/indexer/types.ts b/packages/backend/server/src/plugins/indexer/types.ts
index 1d20f3c845..ae7c161c51 100644
--- a/packages/backend/server/src/plugins/indexer/types.ts
+++ b/packages/backend/server/src/plugins/indexer/types.ts
@@ -9,6 +9,7 @@ import {
} from '@nestjs/graphql';
import { GraphQLJSONObject } from 'graphql-scalars';
+import { PublicUserType } from '../../core/user';
import { PublicUser } from '../../models';
import { SearchTable } from './tables';
@@ -171,6 +172,18 @@ export class AggregateInput {
options!: AggregateOptions;
}
+@InputType()
+export class SearchDocsInput {
+ @Field(() => String)
+ keyword!: string;
+
+ @Field({
+ nullable: true,
+ description: 'Limit the number of docs to return, default is 20',
+ })
+ limit?: number;
+}
+
@ObjectType()
export class BlockObjectType {
@Field(() => [String], { nullable: true })
@@ -320,3 +333,30 @@ export class AggregateResultObjectType {
@Field(() => SearchResultPagination)
pagination!: SearchResultPagination;
}
+
+@ObjectType()
+export class SearchDocObjectType implements Partial {
+ @Field(() => String)
+ docId!: string;
+
+ @Field(() => String)
+ title!: string;
+
+ @Field(() => String)
+ blockId!: string;
+
+ @Field(() => String)
+ highlight!: string;
+
+ @Field(() => Date)
+ createdAt!: Date;
+
+ @Field(() => Date)
+ updatedAt!: Date;
+
+ @Field(() => PublicUserType, { nullable: true })
+ createdByUser?: PublicUserType;
+
+ @Field(() => PublicUserType, { nullable: true })
+ updatedByUser?: PublicUserType;
+}
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 836920fcb2..c5615615c0 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -1502,6 +1502,24 @@ type SameSubscriptionRecurringDataType {
recurring: String!
}
+type SearchDocObjectType {
+ blockId: String!
+ createdAt: DateTime!
+ createdByUser: PublicUserType
+ docId: String!
+ highlight: String!
+ title: String!
+ updatedAt: DateTime!
+ updatedByUser: PublicUserType
+}
+
+input SearchDocsInput {
+ keyword: String!
+
+ """Limit the number of docs to return, default is 20"""
+ limit: Int
+}
+
input SearchHighlight {
before: String!
end: String!
@@ -2074,6 +2092,9 @@ type WorkspaceType {
"""Search a specific table"""
search(input: SearchInput!): SearchResultObjectType!
+ """Search docs by keyword"""
+ searchDocs(input: SearchDocsInput!): [SearchDocObjectType!]!
+
"""The team subscription of the workspace, if exists."""
subscription: SubscriptionType
diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts
index 24a49075b5..9e9c40b4b4 100644
--- a/packages/common/graphql/src/graphql/index.ts
+++ b/packages/common/graphql/src/graphql/index.ts
@@ -1419,6 +1419,33 @@ export const indexerAggregateQuery = {
}`,
};
+export const indexerSearchDocsQuery = {
+ id: 'indexerSearchDocsQuery' as const,
+ op: 'indexerSearchDocs',
+ query: `query indexerSearchDocs($id: String!, $input: SearchDocsInput!) {
+ workspace(id: $id) {
+ searchDocs(input: $input) {
+ docId
+ title
+ blockId
+ highlight
+ createdAt
+ updatedAt
+ createdByUser {
+ id
+ name
+ avatarUrl
+ }
+ updatedByUser {
+ id
+ name
+ avatarUrl
+ }
+ }
+ }
+}`,
+};
+
export const indexerSearchQuery = {
id: 'indexerSearchQuery' as const,
op: 'indexerSearch',
diff --git a/packages/common/graphql/src/graphql/indexer-search-docs.gql b/packages/common/graphql/src/graphql/indexer-search-docs.gql
new file mode 100644
index 0000000000..b1a7e78ddb
--- /dev/null
+++ b/packages/common/graphql/src/graphql/indexer-search-docs.gql
@@ -0,0 +1,22 @@
+query indexerSearchDocs($id: String!, $input: SearchDocsInput!) {
+ workspace(id: $id) {
+ searchDocs(input: $input) {
+ docId
+ title
+ blockId
+ highlight
+ createdAt
+ updatedAt
+ createdByUser {
+ id
+ name
+ avatarUrl
+ }
+ updatedByUser {
+ id
+ name
+ avatarUrl
+ }
+ }
+ }
+}
diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts
index f7918f1b48..05a1baa13c 100644
--- a/packages/common/graphql/src/schema.ts
+++ b/packages/common/graphql/src/schema.ts
@@ -2063,6 +2063,24 @@ export interface SameSubscriptionRecurringDataType {
recurring: Scalars['String']['output'];
}
+export interface SearchDocObjectType {
+ __typename?: 'SearchDocObjectType';
+ blockId: Scalars['String']['output'];
+ createdAt: Scalars['DateTime']['output'];
+ createdByUser: Maybe;
+ docId: Scalars['String']['output'];
+ highlight: Scalars['String']['output'];
+ title: Scalars['String']['output'];
+ updatedAt: Scalars['DateTime']['output'];
+ updatedByUser: Maybe;
+}
+
+export interface SearchDocsInput {
+ keyword: Scalars['String']['input'];
+ /** Limit the number of docs to return, default is 20 */
+ limit?: InputMaybe;
+}
+
export interface SearchHighlight {
before: Scalars['String']['input'];
end: Scalars['String']['input'];
@@ -2649,6 +2667,8 @@ export interface WorkspaceType {
role: Permission;
/** Search a specific table */
search: SearchResultObjectType;
+ /** Search docs by keyword */
+ searchDocs: Array;
/** The team subscription of the workspace, if exists. */
subscription: Maybe;
/** if workspace is team workspace */
@@ -2700,6 +2720,10 @@ export interface WorkspaceTypeSearchArgs {
input: SearchInput;
}
+export interface WorkspaceTypeSearchDocsArgs {
+ input: SearchDocsInput;
+}
+
export interface WorkspaceUserType {
__typename?: 'WorkspaceUserType';
avatarUrl: Maybe;
@@ -4339,6 +4363,39 @@ export type IndexerAggregateQuery = {
};
};
+export type IndexerSearchDocsQueryVariables = Exact<{
+ id: Scalars['String']['input'];
+ input: SearchDocsInput;
+}>;
+
+export type IndexerSearchDocsQuery = {
+ __typename?: 'Query';
+ workspace: {
+ __typename?: 'WorkspaceType';
+ searchDocs: Array<{
+ __typename?: 'SearchDocObjectType';
+ docId: string;
+ title: string;
+ blockId: string;
+ highlight: string;
+ createdAt: string;
+ updatedAt: string;
+ createdByUser: {
+ __typename?: 'PublicUserType';
+ id: string;
+ name: string;
+ avatarUrl: string | null;
+ } | null;
+ updatedByUser: {
+ __typename?: 'PublicUserType';
+ id: string;
+ name: string;
+ avatarUrl: string | null;
+ } | null;
+ }>;
+ };
+};
+
export type IndexerSearchQueryVariables = Exact<{
id: Scalars['String']['input'];
input: SearchInput;
@@ -5302,6 +5359,11 @@ export type Queries =
variables: IndexerAggregateQueryVariables;
response: IndexerAggregateQuery;
}
+ | {
+ name: 'indexerSearchDocsQuery';
+ variables: IndexerSearchDocsQueryVariables;
+ response: IndexerSearchDocsQuery;
+ }
| {
name: 'indexerSearchQuery';
variables: IndexerSearchQueryVariables;