mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +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:
@@ -3,4 +3,13 @@ DB_VERSION=16
|
||||
# database credentials
|
||||
DB_PASSWORD=affine
|
||||
DB_USERNAME=affine
|
||||
DB_DATABASE_NAME=affine
|
||||
DB_DATABASE_NAME=affine
|
||||
|
||||
# elasticsearch env
|
||||
# ELASTIC_VERSION=9.0.1
|
||||
# enable for arm64, e.g.: macOS M1+
|
||||
# ELASTIC_VERSION_ARM64=-arm64
|
||||
# ELASTIC_PLATFORM=linux/arm64
|
||||
|
||||
# manticoresearch
|
||||
MANTICORE_VERSION=9.2.14
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
name: affine_dev_services
|
||||
services:
|
||||
postgres:
|
||||
env_file:
|
||||
- .env
|
||||
image: pgvector/pgvector:pg${DB_VERSION:-16}
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-9.0.1}${ELASTIC_VERSION_ARM64}
|
||||
platform: ${ELASTIC_PLATFORM}
|
||||
labels:
|
||||
co.elastic.logs/module: elasticsearch
|
||||
volumes:
|
||||
- elasticsearch_data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- ${ES_PORT:-9200}:9200
|
||||
environment:
|
||||
- node.name=es01
|
||||
- cluster.name=affine-dev
|
||||
- discovery.type=single-node
|
||||
- bootstrap.memory_lock=true
|
||||
- xpack.security.enabled=false
|
||||
- xpack.security.http.ssl.enabled=false
|
||||
- xpack.security.transport.ssl.enabled=false
|
||||
- xpack.license.self_generated.type=basic
|
||||
mem_limit: ${ES_MEM_LIMIT:-1073741824}
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -s http://localhost:9200 | grep -q 'affine-dev'",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
|
||||
networks:
|
||||
dev:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
elasticsearch_data:
|
||||
@@ -24,8 +24,26 @@ services:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
|
||||
# https://manual.manticoresearch.com/Starting_the_server/Docker
|
||||
manticoresearch:
|
||||
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.2.14}
|
||||
restart: always
|
||||
ports:
|
||||
- 9308:9308
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
volumes:
|
||||
- manticoresearch_data:/var/lib/manticore
|
||||
|
||||
networks:
|
||||
dev:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
manticoresearch_data:
|
||||
|
||||
@@ -20,4 +20,9 @@ CONFIG_LOCATION=~/.affine/self-host/config
|
||||
# database credentials
|
||||
DB_USERNAME=affine
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=affine
|
||||
DB_DATABASE=affine
|
||||
|
||||
# indexer search provider manticoresearch version
|
||||
MANTICORE_VERSION=9.2.14
|
||||
# position of the manticoresearch data to persist
|
||||
MANTICORE_DATA_LOCATION=~/.affine/self-host/manticore
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
indexer:
|
||||
condition: service_healthy
|
||||
affine_migration:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
@@ -41,6 +43,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
indexer:
|
||||
condition: service_healthy
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
@@ -72,3 +76,24 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
indexer:
|
||||
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.2.14}
|
||||
container_name: affine_indexer
|
||||
volumes:
|
||||
- ${MANTICORE_DATA_LOCATION}:/var/lib/manticore
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
healthcheck:
|
||||
test:
|
||||
['CMD', 'wget', '-O-', 'http://127.0.0.1:9308']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -794,6 +794,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"indexer": {
|
||||
"type": "object",
|
||||
"description": "Configuration for indexer module",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable indexer plugin\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"provider.type": {
|
||||
"type": "string",
|
||||
"description": "Indexer search service provider name\n@default \"manticoresearch\"\n@environment `AFFINE_INDEXER_SEARCH_PROVIDER`",
|
||||
"default": "manticoresearch"
|
||||
},
|
||||
"provider.endpoint": {
|
||||
"type": "string",
|
||||
"description": "Indexer search service endpoint\n@default \"http://localhost:9308\"\n@environment `AFFINE_INDEXER_SEARCH_ENDPOINT`",
|
||||
"default": "http://localhost:9308"
|
||||
},
|
||||
"provider.username": {
|
||||
"type": "string",
|
||||
"description": "Indexer search service auth username, if not set, basic auth will be disabled. Optional for elasticsearch\n@default \"\"\n@environment `AFFINE_INDEXER_SEARCH_USERNAME`\n@link https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html",
|
||||
"default": ""
|
||||
},
|
||||
"provider.password": {
|
||||
"type": "string",
|
||||
"description": "Indexer search service auth password, if not set, basic auth will be disabled. Optional for elasticsearch\n@default \"\"\n@environment `AFFINE_INDEXER_SEARCH_PASSWORD`",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"type": "object",
|
||||
"description": "Configuration for oauth module",
|
||||
|
||||
@@ -16,6 +16,10 @@ const {
|
||||
REDIS_SERVER_HOST,
|
||||
REDIS_SERVER_PASSWORD,
|
||||
STATIC_IP_NAME,
|
||||
AFFINE_INDEXER_SEARCH_PROVIDER,
|
||||
AFFINE_INDEXER_SEARCH_ENDPOINT,
|
||||
AFFINE_INDEXER_SEARCH_USERNAME,
|
||||
AFFINE_INDEXER_SEARCH_PASSWORD,
|
||||
} = process.env;
|
||||
|
||||
const buildType = BUILD_TYPE || 'canary';
|
||||
@@ -81,6 +85,12 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string global.redis.password="${REDIS_SERVER_PASSWORD}"`,
|
||||
]
|
||||
: [];
|
||||
const indexerOptions = [
|
||||
`--set-string global.indexer.provider="${AFFINE_INDEXER_SEARCH_PROVIDER}"`,
|
||||
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
||||
`--set-string global.indexer.username="${AFFINE_INDEXER_SEARCH_USERNAME}"`,
|
||||
`--set-string global.indexer.password="${AFFINE_INDEXER_SEARCH_PASSWORD}"`,
|
||||
];
|
||||
const serviceAnnotations = [
|
||||
`--set-json web.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
||||
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
||||
@@ -130,6 +140,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string global.ingress.host="${host}"`,
|
||||
`--set-string global.version="${APP_VERSION}"`,
|
||||
...redisAndPostgres,
|
||||
...indexerOptions,
|
||||
`--set web.replicaCount=${replica.web}`,
|
||||
`--set-string web.image.tag="${imageTag}"`,
|
||||
`--set graphql.replicaCount=${replica.graphql}`,
|
||||
|
||||
@@ -69,6 +69,17 @@ spec:
|
||||
key: redis-password
|
||||
- name: REDIS_SERVER_DATABASE
|
||||
value: "{{ .Values.global.redis.database }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_USERNAME
|
||||
value: "{{ .Values.global.indexer.username }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-password
|
||||
- name: AFFINE_SERVER_PORT
|
||||
value: "{{ .Values.global.docService.port }}"
|
||||
- name: AFFINE_SERVER_SUB_PATH
|
||||
|
||||
@@ -67,6 +67,17 @@ spec:
|
||||
key: redis-password
|
||||
- name: REDIS_SERVER_DATABASE
|
||||
value: "{{ .Values.global.redis.database }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_USERNAME
|
||||
value: "{{ .Values.global.indexer.username }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-password
|
||||
- name: AFFINE_SERVER_PORT
|
||||
value: "{{ .Values.service.port }}"
|
||||
- name: AFFINE_SERVER_SUB_PATH
|
||||
|
||||
@@ -44,6 +44,17 @@ spec:
|
||||
secretKeyRef:
|
||||
name: redis
|
||||
key: redis-password
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_USERNAME
|
||||
value: "{{ .Values.global.indexer.username }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-password
|
||||
resources:
|
||||
requests:
|
||||
cpu: '100m'
|
||||
|
||||
@@ -69,6 +69,17 @@ spec:
|
||||
key: redis-password
|
||||
- name: REDIS_SERVER_DATABASE
|
||||
value: "{{ .Values.global.redis.database }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_USERNAME
|
||||
value: "{{ .Values.global.indexer.username }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-password
|
||||
- name: AFFINE_SERVER_PORT
|
||||
value: "{{ .Values.service.port }}"
|
||||
- name: AFFINE_SERVER_SUB_PATH
|
||||
|
||||
@@ -69,6 +69,17 @@ spec:
|
||||
key: redis-password
|
||||
- name: REDIS_SERVER_DATABASE
|
||||
value: "{{ .Values.global.redis.database }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_USERNAME
|
||||
value: "{{ .Values.global.indexer.username }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-password
|
||||
- name: AFFINE_SERVER_PORT
|
||||
value: "{{ .Values.service.port }}"
|
||||
- name: AFFINE_SERVER_HOST
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.global.indexer.password -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: indexer
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-2"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation
|
||||
type: Opaque
|
||||
data:
|
||||
indexer-password: {{ .Values.global.indexer.password | b64enc }}
|
||||
{{- end }}
|
||||
@@ -21,6 +21,11 @@ global:
|
||||
username: ''
|
||||
password: ''
|
||||
database: 0
|
||||
indexer:
|
||||
provider: ''
|
||||
endpoint: ''
|
||||
username: ''
|
||||
password: ''
|
||||
docService:
|
||||
name: 'affine-doc'
|
||||
port: 3020
|
||||
|
||||
@@ -577,7 +577,25 @@ jobs:
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
manticoresearch:
|
||||
image: manticoresearch/manticore:9.2.14
|
||||
ports:
|
||||
- 9308:9308
|
||||
steps:
|
||||
# https://github.com/elastic/elastic-github-actions/blob/master/elasticsearch/README.md
|
||||
- name: Configure sysctl limits for Elasticsearch
|
||||
run: |
|
||||
sudo swapoff -a
|
||||
sudo sysctl -w vm.swappiness=1
|
||||
sudo sysctl -w fs.file-max=262144
|
||||
sudo sysctl -w vm.max_map_count=262144
|
||||
|
||||
- name: Runs Elasticsearch
|
||||
uses: elastic/elastic-github-actions/elasticsearch@master
|
||||
with:
|
||||
stack-version: 9.0.1
|
||||
security-enabled: false
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -639,6 +657,10 @@ jobs:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
indexer:
|
||||
image: manticoresearch/manticore:9.2.14
|
||||
ports:
|
||||
- 9308:9308
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -1076,6 +1098,10 @@ jobs:
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
indexer:
|
||||
image: manticoresearch/manticore:9.2.14
|
||||
ports:
|
||||
- 9308:9308
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ jobs:
|
||||
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}
|
||||
APP_IAM_ACCOUNT: ${{ secrets.APP_IAM_ACCOUNT }}
|
||||
STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }}
|
||||
AFFINE_INDEXER_SEARCH_PROVIDER: ${{ secrets.AFFINE_INDEXER_SEARCH_PROVIDER }}
|
||||
AFFINE_INDEXER_SEARCH_ENDPOINT: ${{ secrets.AFFINE_INDEXER_SEARCH_ENDPOINT }}
|
||||
AFFINE_INDEXER_SEARCH_USERNAME: ${{ secrets.AFFINE_INDEXER_SEARCH_USERNAME }}
|
||||
AFFINE_INDEXER_SEARCH_PASSWORD: ${{ secrets.AFFINE_INDEXER_SEARCH_PASSWORD }}
|
||||
|
||||
deploy-done:
|
||||
needs:
|
||||
|
||||
@@ -38,3 +38,5 @@ packages/frontend/apps/ios/App/**
|
||||
tests/blocksuite/snapshots
|
||||
blocksuite/docs/api/**
|
||||
packages/frontend/admin/src/config.json
|
||||
**/test-docs.json
|
||||
**/test-blocks.json
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@
|
||||
"packages/frontend/apps/ios/App/**",
|
||||
"tests/blocksuite/snapshots",
|
||||
"blocksuite/docs/api/**",
|
||||
"packages/frontend/admin/src/config.json"
|
||||
"packages/frontend/admin/src/config.json",
|
||||
"**/test-docs.json",
|
||||
"**/test-blocks.json"
|
||||
],
|
||||
"rules": {
|
||||
"no-await-in-loop": "allow",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { serverConfigQuery, ServerFeature } from '@affine/graphql';
|
||||
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should indexer feature enabled by default', async t => {
|
||||
const { serverConfig } = await app.gql({ query: serverConfigQuery });
|
||||
t.is(
|
||||
serverConfig.features.includes(ServerFeature.Indexer),
|
||||
true,
|
||||
JSON.stringify(serverConfig, null, 2)
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
# Snapshot report for `src/__tests__/e2e/indexer/aggregate.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `aggregate.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should aggregate by docId
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
count: 3,
|
||||
hits: {
|
||||
nodes: [
|
||||
{
|
||||
fields: {
|
||||
blockId: [
|
||||
'block-2',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'test3 <b>hello</b> title top1',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
blockId: [
|
||||
'block-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:text',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'test1 <b>hello world</b> top2',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
key: 'doc-0',
|
||||
},
|
||||
{
|
||||
count: 1,
|
||||
hits: {
|
||||
nodes: [
|
||||
{
|
||||
fields: {
|
||||
blockId: [
|
||||
'block-3',
|
||||
],
|
||||
flavour: [
|
||||
'affine:text',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'test4 <b>hello world</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
key: 'doc-1',
|
||||
},
|
||||
{
|
||||
count: 1,
|
||||
hits: {
|
||||
nodes: [
|
||||
{
|
||||
fields: {
|
||||
blockId: [
|
||||
'block-4',
|
||||
],
|
||||
flavour: [
|
||||
'affine:text',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'test5 <b>hello</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
key: 'doc-2',
|
||||
},
|
||||
]
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
# Snapshot report for `src/__tests__/e2e/indexer/search.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `search.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should search with query
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
ref: [
|
||||
'{"foo": "bar1"}',
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
refDocId: [
|
||||
'doc-0',
|
||||
'doc-2',
|
||||
],
|
||||
},
|
||||
highlights: null,
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
ref: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
refDocId: [
|
||||
'doc-0',
|
||||
],
|
||||
},
|
||||
highlights: null,
|
||||
},
|
||||
]
|
||||
Binary file not shown.
@@ -0,0 +1,159 @@
|
||||
import { indexerAggregateQuery, SearchTable } from '@affine/graphql';
|
||||
|
||||
import { IndexerService } from '../../../plugins/indexer/service';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should aggregate by docId', async t => {
|
||||
const owner = await app.signup();
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const indexerService = app.get(IndexerService);
|
||||
|
||||
await indexerService.write(
|
||||
SearchTable.block,
|
||||
[
|
||||
{
|
||||
docId: 'doc-0',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test1 hello world top2',
|
||||
flavour: 'affine:text',
|
||||
blockId: 'block-0',
|
||||
createdByUserId: owner.id,
|
||||
updatedByUserId: owner.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
docId: 'doc-0',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test2 hello hello top3',
|
||||
flavour: 'affine:text',
|
||||
blockId: 'block-1',
|
||||
createdByUserId: owner.id,
|
||||
updatedByUserId: owner.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
docId: 'doc-0',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test3 hello title top1',
|
||||
flavour: 'affine:page',
|
||||
blockId: 'block-2',
|
||||
createdByUserId: owner.id,
|
||||
updatedByUserId: owner.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
docId: 'doc-1',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test4 hello world',
|
||||
flavour: 'affine:text',
|
||||
blockId: 'block-3',
|
||||
refDocId: 'doc-0',
|
||||
ref: ['{"foo": "bar1"}'],
|
||||
createdByUserId: owner.id,
|
||||
updatedByUserId: owner.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
docId: 'doc-2',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test5 hello',
|
||||
flavour: 'affine:text',
|
||||
blockId: 'block-4',
|
||||
refDocId: 'doc-0',
|
||||
ref: ['{"foo": "bar2"}'],
|
||||
createdByUserId: owner.id,
|
||||
updatedByUserId: owner.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
{
|
||||
refresh: true,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await app.gql({
|
||||
query: indexerAggregateQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'boolean',
|
||||
// @ts-expect-error allow to use string as enum
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'match',
|
||||
field: 'content',
|
||||
match: 'hello world',
|
||||
},
|
||||
{
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'boolean',
|
||||
// @ts-expect-error allow to use string as enum
|
||||
occur: 'should',
|
||||
queries: [
|
||||
{
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'match',
|
||||
field: 'content',
|
||||
match: 'hello world',
|
||||
},
|
||||
{
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'boost',
|
||||
boost: 1.5,
|
||||
query: {
|
||||
// @ts-expect-error allow to use string as enum
|
||||
type: 'match',
|
||||
field: 'flavour',
|
||||
match: 'affine:page',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'docId',
|
||||
options: {
|
||||
pagination: {
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
},
|
||||
hits: {
|
||||
pagination: {
|
||||
limit: 2,
|
||||
skip: 0,
|
||||
},
|
||||
fields: ['blockId', 'flavour'],
|
||||
highlights: [
|
||||
{
|
||||
field: 'content',
|
||||
before: '<b>',
|
||||
end: '</b>',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.workspace.aggregate, 'failed to aggregate');
|
||||
t.is(result.workspace.aggregate.pagination.count, 5);
|
||||
t.is(result.workspace.aggregate.pagination.hasMore, true);
|
||||
t.truthy(result.workspace.aggregate.pagination.nextCursor);
|
||||
t.snapshot(result.workspace.aggregate.buckets);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
indexerSearchQuery,
|
||||
SearchQueryOccur,
|
||||
SearchQueryType,
|
||||
SearchTable,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { IndexerService } from '../../../plugins/indexer/service';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should search with query', async t => {
|
||||
const owner = await app.signup();
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const indexerService = app.get(IndexerService);
|
||||
|
||||
await indexerService.write(
|
||||
SearchTable.block,
|
||||
[
|
||||
{
|
||||
docId: 'doc-0',
|
||||
workspaceId: workspace.id,
|
||||
content: 'test1',
|
||||
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',
|
||||
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',
|
||||
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-22T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
{
|
||||
refresh: true,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.must,
|
||||
queries: [
|
||||
{
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.should,
|
||||
queries: ['doc-0', 'doc-1', 'doc-2'].map(id => ({
|
||||
type: SearchQueryType.match,
|
||||
field: 'docId',
|
||||
match: id,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: SearchQueryType.exists,
|
||||
field: 'refDocId',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
fields: ['refDocId', 'ref'],
|
||||
pagination: {
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.workspace.search, 'failed to search');
|
||||
t.is(result.workspace.search.pagination.count, 2);
|
||||
t.is(result.workspace.search.pagination.hasMore, true);
|
||||
t.truthy(result.workspace.search.pagination.nextCursor);
|
||||
t.is(result.workspace.search.nodes.length, 2);
|
||||
t.snapshot(result.workspace.search.nodes);
|
||||
});
|
||||
@@ -49,6 +49,7 @@ import { CaptchaModule } from './plugins/captcha';
|
||||
import { CopilotModule } from './plugins/copilot';
|
||||
import { CustomerIoModule } from './plugins/customerio';
|
||||
import { GCloudModule } from './plugins/gcloud';
|
||||
import { IndexerModule } from './plugins/indexer';
|
||||
import { LicenseModule } from './plugins/license';
|
||||
import { OAuthModule } from './plugins/oauth';
|
||||
import { PaymentModule } from './plugins/payment';
|
||||
@@ -146,7 +147,8 @@ export function buildAppModule(env: Env) {
|
||||
// enable schedule module on graphql server and doc service
|
||||
.useIf(
|
||||
() => env.flavors.graphql || env.flavors.doc,
|
||||
ScheduleModule.forRoot()
|
||||
ScheduleModule.forRoot(),
|
||||
IndexerModule
|
||||
)
|
||||
|
||||
// auth
|
||||
|
||||
@@ -861,4 +861,21 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'invalid_input',
|
||||
message: 'Invalid app config.',
|
||||
},
|
||||
|
||||
// indexer errors
|
||||
search_provider_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Search provider not found.',
|
||||
},
|
||||
invalid_search_provider_request: {
|
||||
type: 'invalid_input',
|
||||
args: { reason: 'string', type: 'string' },
|
||||
message: ({ reason }) =>
|
||||
`Invalid request argument to search provider: ${reason}`,
|
||||
},
|
||||
invalid_indexer_input: {
|
||||
type: 'invalid_input',
|
||||
args: { reason: 'string' },
|
||||
message: ({ reason }) => `Invalid indexer input: ${reason}`,
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
||||
@@ -991,6 +991,33 @@ export class InvalidAppConfig extends UserFriendlyError {
|
||||
super('invalid_input', 'invalid_app_config', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SearchProviderNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('resource_not_found', 'search_provider_not_found', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidSearchProviderRequestDataType {
|
||||
@Field() reason!: string
|
||||
@Field() type!: string
|
||||
}
|
||||
|
||||
export class InvalidSearchProviderRequest extends UserFriendlyError {
|
||||
constructor(args: InvalidSearchProviderRequestDataType, message?: string | ((args: InvalidSearchProviderRequestDataType) => string)) {
|
||||
super('invalid_input', 'invalid_search_provider_request', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class InvalidIndexerInputDataType {
|
||||
@Field() reason!: string
|
||||
}
|
||||
|
||||
export class InvalidIndexerInput extends UserFriendlyError {
|
||||
constructor(args: InvalidIndexerInputDataType, message?: string | ((args: InvalidIndexerInputDataType) => string)) {
|
||||
super('invalid_input', 'invalid_indexer_input', message, args);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NETWORK_ERROR,
|
||||
@@ -1118,7 +1145,10 @@ export enum ErrorNames {
|
||||
NOTIFICATION_NOT_FOUND,
|
||||
MENTION_USER_DOC_ACCESS_DENIED,
|
||||
MENTION_USER_ONESELF_DENIED,
|
||||
INVALID_APP_CONFIG
|
||||
INVALID_APP_CONFIG,
|
||||
SEARCH_PROVIDER_NOT_FOUND,
|
||||
INVALID_SEARCH_PROVIDER_REQUEST,
|
||||
INVALID_INDEXER_INPUT
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
@@ -1127,5 +1157,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export class PaginationInput {
|
||||
transform: value => {
|
||||
return {
|
||||
...value,
|
||||
after: decode(value.after),
|
||||
after: decode(value?.after),
|
||||
// before: decode(value.before),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -105,6 +105,9 @@ export class OpentelemetryProvider {
|
||||
|
||||
@OnEvent('config.init')
|
||||
async init(event: Events['config.init']) {
|
||||
if (env.flavors.script) {
|
||||
return;
|
||||
}
|
||||
if (event.config.metrics.enabled) {
|
||||
await this.setup();
|
||||
registerCustomMetrics();
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum ServerFeature {
|
||||
Copilot = 'copilot',
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
Indexer = 'indexer',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FunctionalityModules } from '../app.module';
|
||||
import { IndexerModule } from '../plugins/indexer';
|
||||
import { CreateCommand, NameQuestion } from './commands/create';
|
||||
import { ImportConfigCommand } from './commands/import';
|
||||
import { RevertCommand, RunCommand } from './commands/run';
|
||||
|
||||
@Module({
|
||||
imports: FunctionalityModules,
|
||||
imports: [...FunctionalityModules, IndexerModule],
|
||||
providers: [
|
||||
NameQuestion,
|
||||
CreateCommand,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { IndexerService } from '../../plugins/indexer';
|
||||
|
||||
export class CreateIndexerTables1745211351719 {
|
||||
static always = true;
|
||||
|
||||
// do the migration
|
||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
||||
await ref.get(IndexerService, { strict: false }).createTables();
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {}
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './1721299086340-refresh-unnamed-user';
|
||||
export * from './1732861452428-migrate-invite-status';
|
||||
export * from './1733125339942-universal-subscription';
|
||||
export * from './1738590347632-feature-redundant';
|
||||
export * from './1745211351719-create-indexer-tables';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/title/blockId1", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId1", "content" : "title1 hello, 这是一段包含中文的标题,hello 你好😄", "flavour" : "title", "blob" : "blob1", "ref_doc_id" : "refDocId1", "ref" : "ref1", "parent_flavour" : "parentFlavour1", "parent_block_id" : "parentBlockId1", "additional" : "additional1", "markdown_preview" : "markdownPreview1", "created_by_user_id" : "userId1", "updated_by_user_id" : "userId1", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-10T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/flavour2/blockId2", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId2", "content" : "title2 world, test searching morphology", "flavour" : "flavour2", "blob" : "blob2", "ref_doc_id" : "refDocId2", "ref" : "ref2", "parent_flavour" : "parentFlavour2", "parent_block_id" : "parentBlockId2", "additional" : "additional2", "markdown_preview" : "markdownPreview2", "created_by_user_id" : "userId2", "updated_by_user_id" : "userId2", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/flavour3/blockId3", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId3", "content" : "title3 hello update", "flavour" : "flavour3", "blob" : "blob3", "ref_doc_id" : "refDocId3", "ref" : "ref3", "parent_flavour" : "parentFlavour3", "parent_block_id" : "parentBlockId3", "additional" : "additional3", "markdown_preview" : "markdownPreview3", "created_by_user_id" : "userId3", "updated_by_user_id" : "userId3", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-09T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/flavour4/blockId4", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId4", "content" : "title4 hello", "flavour" : "flavour4", "blob" : "blob4", "ref_doc_id" : "refDocId4", "ref" : "ref4", "parent_flavour" : "parentFlavour4", "parent_block_id" : "parentBlockId4", "additional" : "additional4", "markdown_preview" : "markdownPreview4", "created_by_user_id" : "userId4", "updated_by_user_id" : "userId4", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/flavour5/blockId5", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId5", "content" : "title5 hello", "flavour" : "flavour5", "blob" : "blob5", "ref_doc_id" : "refDocId5", "ref" : "ref5", "parent_flavour" : "parentFlavour5", "parent_block_id" : "parentBlockId5", "additional" : "additional5", "markdown_preview" : "markdownPreview5", "created_by_user_id" : "userId5", "updated_by_user_id" : "userId5", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId1/flavour6/blockId6", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "block_id" : "blockId6", "content" : "title6 hello", "flavour" : "flavour6", "blob" : "blob6", "ref_doc_id" : "refDocId6", "ref" : "ref6", "parent_flavour" : "parentFlavour6", "parent_block_id" : "parentBlockId6", "additional" : "additional6", "markdown_preview" : "markdownPreview6", "created_by_user_id" : "userId6", "updated_by_user_id" : "userId6", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId1/flavour7/blockId7", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId2", "doc_id" : "docId1", "block_id" : "blockId7", "content" : "title7 hello", "flavour" : "flavour7", "blob" : "blob7", "ref_doc_id" : "refDocId7", "ref" : "ref7", "parent_flavour" : "parentFlavour7", "parent_block_id" : "parentBlockId7", "additional" : "additional7", "markdown_preview" : "markdownPreview7", "created_by_user_id" : "userId7", "updated_by_user_id" : "userId7", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId2/affine:page/blockId9", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId9", "block_id" : "blockId9", "content" : "title9 hello affine issue hello hello hello hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello", "flavour" : "affine:page", "flavour_indexed": "affine:page", "parent_flavour": "parentFlavour9", "parent_block_id" : "parentBlockId9", "additional" : "additional9", "markdown_preview" : "markdownPreview9", "created_by_user_id" : "userId9", "updated_by_user_id" : "userId9", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId2/affine:page/blockId10", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId2", "block_id" : "blockId10", "content" : "this is docId2 title content hello", "flavour" : "affine:page", "flavour_indexed": "affine:page", "parent_flavour": "parentFlavour10", "parent_block_id" : "parentBlockId10", "additional" : "additional10", "markdown_preview" : "markdownPreview10", "created_by_user_id" : "userId10", "updated_by_user_id" : "userId10", "created_at" : "2023-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId2/affine:page/blockId11", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId2", "block_id" : "blockId11", "content" : "this is docId2 title content world", "flavour" : "affine:page", "flavour_indexed": "affine:page", "parent_flavour": "parentFlavour11", "parent_block_id" : "parentBlockId11", "additional" : "additional11", "markdown_preview" : "markdownPreview11", "created_by_user_id" : "userId11", "updated_by_user_id" : "userId11", "created_at" : "2023-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId2/affine:page/blockId12", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId2", "block_id" : "blockId12", "content" : "this is docId2 title content world", "flavour" : "affine:page", "flavour_indexed": "affine:page", "parent_flavour": "parentFlavour12", "parent_block_id" : "parentBlockId12", "additional" : "additional12", "markdown_preview" : "markdownPreview12", "created_by_user_id" : "userId12", "updated_by_user_id" : "userId12", "created_at" : "2023-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z", "ref_doc_id" : "docId2"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId3/affine:page/blockId13", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId3", "block_id" : "blockId13", "content" : "this is docId3 title content world", "flavour" : "affine:page", "flavour_indexed": "affine:page", "parent_flavour": "parentFlavour13", "parent_block_id" : "parentBlockId13", "additional" : "additional13", "markdown_preview" : "markdownPreview13", "created_by_user_id" : "userId13", "updated_by_user_id" : "userId13", "created_at" : "2023-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z", "ref_doc_id" : "docId2"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId3/affine:database/blockId14", "_index" : "block"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId3", "block_id" : "blockId14", "content" : "this is docId3 title content world", "flavour" : "affine:database", "parent_flavour": "affine:database", "parent_block_id" : "parentBlockId14", "additional" : "additional14", "markdown_preview" : "markdownPreview14", "created_by_user_id" : "userId14", "updated_by_user_id" : "userId14", "created_at" : "2023-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z", "ref_doc_id" : "docId2"}
|
||||
@@ -0,0 +1,22 @@
|
||||
{ "index" : {"_id" : "workspaceId1/docId1", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId1", "title" : "title1 hello, 这是一段包含中文的标题,hello 你好😄", "summary" : "summary1", "journal" : "journal1", "created_by_user_id" : "userId1", "updated_by_user_id" : "userId1", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-10T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId2", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId2", "title" : "title2 world, test searching morphology", "summary" : "summary2", "journal" : "journal2", "created_by_user_id" : "userId2", "updated_by_user_id" : "userId2", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId1/docId3", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId3", "title" : "title3 hello update", "summary" : "summary3", "journal" : "journal3", "created_by_user_id" : "userId3", "updated_by_user_id" : "userId3", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-09T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId4", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId2", "doc_id" : "docId4", "title" : "title4 hello", "summary" : "summary4", "journal" : "journal4", "created_by_user_id" : "userId4", "updated_by_user_id" : "userId4", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId5", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId5", "title" : "title5 hello", "summary" : "summary5", "journal" : "journal5", "created_by_user_id" : "userId5", "updated_by_user_id" : "userId5", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId6", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId6", "title" : "title6 hello", "summary" : "summary6", "journal" : "journal6", "created_by_user_id" : "userId6", "updated_by_user_id" : "userId6", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId7", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId7", "title" : "title7 hello", "summary" : "summary7", "journal" : "journal7", "created_by_user_id" : "userId7", "updated_by_user_id" : "userId7", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId2/docId8", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId8", "title" : "title8 hello", "summary" : "summary8", "journal" : "journal8", "created_by_user_id" : "userId8", "updated_by_user_id" : "userId8", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId3/docId9", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId9", "title" : "title9 hello", "summary" : "summary9", "journal" : "journal9", "created_by_user_id" : "userId9", "updated_by_user_id" : "userId9", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId3/docId10", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId10", "title" : "title10 hello", "summary" : "summary10", "journal" : "journal10", "created_by_user_id" : "userId10", "updated_by_user_id" : "userId10", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2025-04-08T06:04:13.278Z"}
|
||||
{ "index" : {"_id" : "workspaceId3/docId10", "_index" : "doc"} }
|
||||
{"workspace_id" : "workspaceId1", "doc_id" : "docId11", "title" : "title11 hello, old value", "summary" : "summary11", "journal" : "journal11", "created_by_user_id" : "userId11", "updated_by_user_id" : "userId11", "created_at" : "2025-03-08T06:04:13.278Z", "updated_at" : "2024-04-08T06:04:13.278Z"}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
# Snapshot report for `src/plugins/indexer/__tests__/service.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `service.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should write block with array content work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should parse all query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
],
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse exists query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
],
|
||||
query: {
|
||||
exists: {
|
||||
field: 'ref_doc_id',
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse boost query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
],
|
||||
query: {
|
||||
term: {
|
||||
flavour: {
|
||||
boost: 1.5,
|
||||
value: 'affine:page',
|
||||
},
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse match query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
'parent_flavour',
|
||||
'parent_block_id',
|
||||
'additional',
|
||||
'markdown_preview',
|
||||
'created_by_user_id',
|
||||
'updated_by_user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
query: {
|
||||
term: {
|
||||
flavour: {
|
||||
value: 'affine:page',
|
||||
},
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse boolean query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
'parent_flavour',
|
||||
'parent_block_id',
|
||||
'additional',
|
||||
'markdown_preview',
|
||||
'created_by_user_id',
|
||||
'updated_by_user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
term: {
|
||||
workspace_id: {
|
||||
value: 'workspaceId1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
content: {
|
||||
query: 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
content: {
|
||||
query: 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
flavour: {
|
||||
boost: 1.5,
|
||||
value: 'affine:page',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse search input highlight work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
],
|
||||
highlight: {
|
||||
fields: {
|
||||
content: {
|
||||
post_tags: [
|
||||
'</b>',
|
||||
],
|
||||
pre_tags: [
|
||||
'<b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should parse aggregate input highlight work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
aggs: {
|
||||
result: {
|
||||
aggs: {
|
||||
max_score: {
|
||||
max: {
|
||||
script: {
|
||||
source: '_score',
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
top_hits: {
|
||||
_source: [
|
||||
'workspace_id',
|
||||
'doc_id',
|
||||
],
|
||||
fields: [
|
||||
'flavour',
|
||||
'doc_id',
|
||||
'ref_doc_id',
|
||||
],
|
||||
highlight: {
|
||||
fields: {
|
||||
content: {
|
||||
post_tags: [
|
||||
'</b>',
|
||||
],
|
||||
pre_tags: [
|
||||
'<b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
terms: {
|
||||
field: 'flavour',
|
||||
order: {
|
||||
max_score: 'desc',
|
||||
},
|
||||
size: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
updated_at: 'desc',
|
||||
},
|
||||
'id',
|
||||
],
|
||||
}
|
||||
|
||||
## should search work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
summary: [
|
||||
'this is a test',
|
||||
],
|
||||
title: [
|
||||
'hello world',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
title: [
|
||||
'<b>hello</b> world',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
summary: [
|
||||
'这是测试',
|
||||
],
|
||||
title: [
|
||||
'你好世界',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
title: [
|
||||
'<b>你好</b> 世界',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should search with exists query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
blockId: [
|
||||
'blockId1',
|
||||
],
|
||||
parentBlockId: [
|
||||
'blockId2',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should search a doc summary work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
summary: [
|
||||
'hello world, this is a summary',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should aggregate with bool must_not query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
count: 2,
|
||||
hits: [
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
markdownPreview: [
|
||||
'hello world, this is a title',
|
||||
],
|
||||
parentBlockId: [
|
||||
'parentBlockId1',
|
||||
],
|
||||
parentFlavour: [
|
||||
'affine:database',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
markdownPreview: [
|
||||
'hello world, this is a title',
|
||||
],
|
||||
parentBlockId: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parentFlavour: [
|
||||
'affine:database',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
count: 1,
|
||||
hits: [
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
markdownPreview: [
|
||||
'hello world, this is a title',
|
||||
],
|
||||
parentBlockId: [
|
||||
'parentBlockId3',
|
||||
],
|
||||
parentFlavour: [
|
||||
'affine:database',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
BIN
Binary file not shown.
+562
@@ -0,0 +1,562 @@
|
||||
# Snapshot report for `src/plugins/indexer/__tests__/providers/elasticsearch.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `elasticsearch.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should search block table query match url work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_id: 'workspaceId1/docId2/blockId8',
|
||||
_source: {
|
||||
doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'additional8',
|
||||
],
|
||||
content: [
|
||||
'title8 hello hello hello hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello some link https://linear.app/affine-design/issue/AF-1379/slash-commands-%E6%BF%80%E6%B4%BB%E6%8F%92%E5%85%A5-link-%E7%9A%84%E5%BC%B9%E7%AA%97%E9%87%8C%EF%BC%8C%E8%BE%93%E5%85%A5%E9%93%BE%E6%8E%A5%E4%B9%8B%E5%90%8E%E4%B8%8D%E5%BA%94%E8%AF%A5%E7%9B%B4%E6%8E%A5%E5%AF%B9%E9%93%BE%E6%8E%A5%E8%BF%9B%E8%A1%8C%E5%88%86%E8%AF%8D%E6%90%9C%E7%B4%A2',
|
||||
],
|
||||
created_at: [
|
||||
'2025-03-08T06:04:13.278Z',
|
||||
],
|
||||
doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
markdown_preview: [
|
||||
'markdownPreview8',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId8',
|
||||
],
|
||||
parent_flavour: [
|
||||
'parentFlavour8',
|
||||
],
|
||||
ref: [
|
||||
'{"docId":"docId1","mode":"page"}',
|
||||
'{"docId":"docId2","mode":"page"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId1',
|
||||
],
|
||||
updated_at: [
|
||||
'2025-03-08T06:04:13.278Z',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'hello hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello some <b>link</b>',
|
||||
'<b>https</b>://<b>linear.app</b>/<b>affine</b>-<b>design</b>/<b>issue</b>/<b>AF</b>-<b>1379</b>/<b>slash</b>-<b>commands</b>-%<b>E6</b>%<b>BF</b>%<b>80</b>%<b>E6</b>%<b>B4</b>%<b>BB</b>%<b>E6</b>%<b>8F</b>%<b>92</b>%<b>E5</b>%<b>85</b>%<b>A5</b>-<b>link</b>',
|
||||
'-%<b>E7</b>%<b>9A</b>%<b>84</b>%<b>E5</b>%<b>BC</b>%<b>B9</b>%<b>E7</b>%<b>AA</b>%<b>97</b>%<b>E9</b>%<b>87</b>%<b>8C</b>%<b>EF</b>%<b>BC</b>%<b>8C</b>%<b>E8</b>%<b>BE</b>%<b>93</b>%<b>E5</b>%<b>85</b>%<b>A5</b>%<b>E9</b>%<b>93</b>%<b>BE</b>%<b>E6</b>%<b>8E</b>%<b>A5</b>%<b>E4</b>%<b>B9</b>%<b>8B</b>%<b>E5</b>%<b>90</b>%<b>8E</b>%',
|
||||
'<b>E4</b>%<b>B8</b>%<b>8D</b>%<b>E5</b>%<b>BA</b>%<b>94</b>%<b>E8</b>%<b>AF</b>%<b>A5</b>%<b>E7</b>%<b>9B</b>%<b>B4</b>%<b>E6</b>%<b>8E</b>%<b>A5</b>%<b>E5</b>%<b>AF</b>%<b>B9</b>%<b>E9</b>%<b>93</b>%<b>BE</b>%<b>E6</b>%<b>8E</b>%<b>A5</b>%<b>E8</b>%<b>BF</b>%<b>9B</b>%<b>E8</b>%<b>A1</b>%<b>8C</b>%<b>E5</b>%<b>88</b>%<b>86</b>%<b>E8</b>%',
|
||||
'<b>AF</b>%<b>8D</b>%<b>E6</b>%<b>90</b>%<b>9C</b>%<b>E7</b>%<b>B4</b>%<b>A2</b>',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
_id: 'workspaceId1/docId2/blockId8',
|
||||
_source: {
|
||||
doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'additional8',
|
||||
],
|
||||
content: [
|
||||
'title8 hello hello hello hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello some link https://linear.app/affine-design/issue/AF-1379/slash-commands-%E6%BF%80%E6%B4%BB%E6%8F%92%E5%85%A5-link-%E7%9A%84%E5%BC%B9%E7%AA%97%E9%87%8C%EF%BC%8C%E8%BE%93%E5%85%A5%E9%93%BE%E6%8E%A5%E4%B9%8B%E5%90%8E%E4%B8%8D%E5%BA%94%E8%AF%A5%E7%9B%B4%E6%8E%A5%E5%AF%B9%E9%93%BE%E6%8E%A5%E8%BF%9B%E8%A1%8C%E5%88%86%E8%AF%8D%E6%90%9C%E7%B4%A2',
|
||||
],
|
||||
created_at: [
|
||||
'2025-03-08T06:04:13.278Z',
|
||||
],
|
||||
doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
markdown_preview: [
|
||||
'markdownPreview8',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId8',
|
||||
],
|
||||
parent_flavour: [
|
||||
'parentFlavour8',
|
||||
],
|
||||
ref: [
|
||||
'{"docId":"docId1","mode":"page"}',
|
||||
'{"docId":"docId2","mode":"page"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId1',
|
||||
],
|
||||
updated_at: [
|
||||
'2025-03-08T06:04:13.278Z',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello some link <b>https</b>',
|
||||
'://<b>linear.app</b>/affine-design/issue/AF-1379/slash-commands-%E6%BF%80%E6%B4%BB%E6%8F%92%E5%85%A5-link-%E7%',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should search block table query content match cjk work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_id: 'workspaceId1/docId2-affine/blockId8',
|
||||
_source: {
|
||||
doc_id: 'docId2-affine',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'AFFiNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
doc_id: [
|
||||
'docId2-affine',
|
||||
],
|
||||
flavour: [
|
||||
'flavour8',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'AFFiNE 是一个基于云端的<b>笔记应用</b>',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
_id: 'workspaceId1/docId2-affine/blockId8',
|
||||
_source: {
|
||||
doc_id: 'docId2-affine',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'AFFiNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
doc_id: [
|
||||
'docId2-affine',
|
||||
],
|
||||
flavour: [
|
||||
'flavour8',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'AFFiNE 是一个基于云端的笔<b>记</b>应用',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should search doc table query title match cjk work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_id: 'workspace-test-doc-title-cjk/doc-0',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspace-test-doc-title-cjk',
|
||||
},
|
||||
fields: {
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
title: [
|
||||
'AFFiNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
title: [
|
||||
'AFFiNE 是一个基于云端的<b>笔记应</b>用',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
_id: 'workspace-test-doc-title-cjk/doc-0',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspace-test-doc-title-cjk',
|
||||
},
|
||||
fields: {
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
title: [
|
||||
'AFFiNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
title: [
|
||||
'AFFiNE 是一个基于云端的<b>笔</b>记应用',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should search doc table query title.autocomplete work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_id: 'workspace-test-doc-title-autocomplete/doc-0',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspace-test-doc-title-autocomplete',
|
||||
},
|
||||
fields: {
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
title: [
|
||||
'AFFiNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
'title.autocomplete': [
|
||||
'<b>AFF</b>iNE 是一个基于云端的笔记应用',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should search query match ref_doc_id work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar0"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId1',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId1',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-1',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId-all',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-2',
|
||||
'doc-3',
|
||||
'doc-4',
|
||||
'doc-5',
|
||||
'doc-6',
|
||||
'doc-7',
|
||||
'doc-8',
|
||||
'doc-9',
|
||||
'doc-10',
|
||||
'doc-1',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId1-2',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-1',
|
||||
'doc-2',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId2-1',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-2',
|
||||
'doc-1',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId3-2-1-4',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-3',
|
||||
'doc-2',
|
||||
'doc-1',
|
||||
'doc-4',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId-all',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-2',
|
||||
'doc-3',
|
||||
'doc-4',
|
||||
'doc-5',
|
||||
'doc-6',
|
||||
'doc-7',
|
||||
'doc-8',
|
||||
'doc-9',
|
||||
'doc-10',
|
||||
'doc-1',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId4',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId4',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc-10',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should aggregate query work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: 'workspaceId1/docId2/affine:page/blockId9',
|
||||
_source: {
|
||||
doc_id: 'docId9',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'blockId9',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'title9 <b>hello</b> affine issue <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b>, <b>hello</b> <b>hello</b> <b>hello</b>',
|
||||
'<b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b> <b>hello</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
## should aggregate query return top score first
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
count: 1,
|
||||
hits: [
|
||||
{
|
||||
_id: 'aggregate-test-workspace-top-score-max-first/doc-0/block-0',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'<b>0.15</b> - <b>week</b>.<b>1</b>进度',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
key: 'doc-0',
|
||||
},
|
||||
{
|
||||
count: 2,
|
||||
hits: [
|
||||
{
|
||||
_id: 'aggregate-test-workspace-top-score-max-first/doc-10/block-10-1',
|
||||
_source: {
|
||||
doc_id: 'doc-10',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-10-1',
|
||||
],
|
||||
flavour: [
|
||||
'affine:paragraph',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'Example <b>1</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: 'aggregate-test-workspace-top-score-max-first/doc-10/block-10-2',
|
||||
_source: {
|
||||
doc_id: 'doc-10',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-10-2',
|
||||
],
|
||||
flavour: [
|
||||
'affine:paragraph',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'Single substitution format <b>1</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
key: 'doc-10',
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
count: 1,
|
||||
hits: [
|
||||
{
|
||||
_id: 'aggregate-test-workspace-top-score-max-first/doc-0/block-0',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'<b>0.15</b> - <b>week</b>.<b>1</b>进度',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
key: 'doc-0',
|
||||
},
|
||||
]
|
||||
BIN
Binary file not shown.
+866
@@ -0,0 +1,866 @@
|
||||
# Snapshot report for `src/plugins/indexer/__tests__/providers/manticoresearch.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `manticoresearch.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should write document work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
flavour_indexed: [
|
||||
'affine:page',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
parent_flavour_indexed: [
|
||||
'affine:database',
|
||||
],
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
}
|
||||
|
||||
> Snapshot 3
|
||||
|
||||
{
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
}
|
||||
|
||||
## should handle ref_doc_id as string[]
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '4676525419549473798',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '{"foo": "bar"}',
|
||||
ref_doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId-ref-doc-id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '4676526519061102009',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '{"foo": "bar2"}',
|
||||
ref_doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId-ref-doc-id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:text',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar2"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
_id: '4676525419549473798',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '["{\\"foo\\": \\"bar\\"}","{\\"foo\\": \\"baz\\"}"]',
|
||||
ref_doc_id: '["docId2","docId3"]',
|
||||
workspace_id: 'workspaceId-ref-doc-id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar"}',
|
||||
'{"foo": "baz"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
'docId3',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '4676526519061102009',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '["{\\"foo\\": \\"bar2\\"}","{\\"foo\\": \\"baz2\\"}"]',
|
||||
ref_doc_id: '["docId2","docId3"]',
|
||||
workspace_id: 'workspaceId-ref-doc-id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:text',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar2"}',
|
||||
'{"foo": "baz2"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
'docId3',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should handle content as string[]
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '8978714848978078536',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '{"foo": "bar"}',
|
||||
ref_doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId-content-as-string-array-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
_id: '8978714848978078536',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
ref: '{"foo": "bar"}',
|
||||
ref_doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId-content-as-string-array-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
content: [
|
||||
'hello world 2',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
ref: [
|
||||
'{"foo": "bar"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should handle blob as string[]
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '8163498729658755634',
|
||||
_source: {
|
||||
blob: 'blob1',
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspaceId-blob-as-string-array-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
blob: [
|
||||
'blob1',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
_id: '8163498729658755634',
|
||||
_source: {
|
||||
blob: '["blob1","blob2"]',
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspaceId-blob-as-string-array-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
blob: [
|
||||
'blob1',
|
||||
'blob2',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 3
|
||||
|
||||
[
|
||||
{
|
||||
_id: '8163498729658755634',
|
||||
_source: {
|
||||
blob: 'blob3',
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspaceId-blob-as-string-array-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
blob: [
|
||||
'blob3',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should search query all and get next cursor work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '1835975812913922715',
|
||||
_score: 1,
|
||||
_source: {
|
||||
doc_id: 'doc-10',
|
||||
workspace_id: 'workspaceId-search-query-all-and-get-next-cursor-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-10',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-10',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
workspace_id: [
|
||||
'workspaceId-search-query-all-and-get-next-cursor-for-manticoresearch',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '1859562045173936129',
|
||||
_score: 1,
|
||||
_source: {
|
||||
doc_id: 'doc-19',
|
||||
workspace_id: 'workspaceId-search-query-all-and-get-next-cursor-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-19',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-19',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
workspace_id: [
|
||||
'workspaceId-search-query-all-and-get-next-cursor-for-manticoresearch',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should filter by workspace_id work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '5890563618264835345',
|
||||
_score: 1,
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspaceId-filter-by-workspace_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'blockId1',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
workspace_id: [
|
||||
'workspaceId-filter-by-workspace_id-for-manticoresearch',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '5890560319729950712',
|
||||
_score: 1,
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'workspaceId-filter-by-workspace_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'blockId2',
|
||||
],
|
||||
doc_id: [
|
||||
'doc-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
workspace_id: [
|
||||
'workspaceId-filter-by-workspace_id-for-manticoresearch',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should search query match url work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
_id: '6109831083726758533',
|
||||
_source: {
|
||||
doc_id: 'docId2',
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'additional8',
|
||||
],
|
||||
content: [
|
||||
'title8 hello hello hello hello hello hello hello hello hello hello, hello hello hello hello hello hello hello hello some link https://linear.app/affine-design/issue/AF-1379/slash-commands-%E6%BF%80%E6%B4%BB%E6%8F%92%E5%85%A5-link-%E7%9A%84%E5%BC%B9%E7%AA%97%E9%87%8C%EF%BC%8C%E8%BE%93%E5%85%A5%E9%93%BE%E6%8E%A5%E4%B9%8B%E5%90%8E%E4%B8%8D%E5%BA%94%E8%AF%A5%E7%9B%B4%E6%8E%A5%E5%AF%B9%E9%93%BE%E6%8E%A5%E8%BF%9B%E8%A1%8C%E5%88%86%E8%AF%8D%E6%90%9C%E7%B4%A2',
|
||||
],
|
||||
created_at: [
|
||||
1741413853,
|
||||
],
|
||||
doc_id: [
|
||||
'docId2',
|
||||
],
|
||||
markdown_preview: [
|
||||
'markdownPreview8',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId8',
|
||||
],
|
||||
parent_flavour: [
|
||||
'parentFlavour8',
|
||||
],
|
||||
ref: [
|
||||
'{"docId":"docId1","mode":"page"}',
|
||||
'{"docId":"docId2","mode":"page"}',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'docId1',
|
||||
],
|
||||
updated_at: [
|
||||
1741413853,
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
' hello hello hello some link <b>https://linear.app/affine-design/issue/AF-1379/slash-commands</b>-%E6%BF%80%E6%B4',
|
||||
'%8D%E5%BA%94%E8%<b>AF</b>%A5%E7%9B%B4%E6',
|
||||
'%8E%A5%E5%<b>AF</b>%B9%E9%93%BE%E6',
|
||||
'%8C%E5%88%86%E8%<b>AF</b>%8D%E6%90%9C%E7',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should search query match ref_doc_id work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
_id: '7273541739182975606',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
parent_flavour: 'affine:database',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar0"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId1',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId1',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc1',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '6397614322515597713',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
parent_flavour: 'affine:database',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId-all',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc2',
|
||||
'doc3',
|
||||
'doc4',
|
||||
'doc5',
|
||||
'doc6',
|
||||
'doc7',
|
||||
'doc8',
|
||||
'doc9',
|
||||
'doc10',
|
||||
'doc1',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '6305665172360896969',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
parent_flavour: 'affine:database',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId1-2',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc1',
|
||||
'doc2',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '5748459067614019233',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
parent_flavour: 'affine:database',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId2-1',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc2',
|
||||
'doc1',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '6824370853640968276',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
parent_flavour: 'affine:database',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId3-2-1-4',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc3',
|
||||
'doc2',
|
||||
'doc1',
|
||||
'doc4',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
[
|
||||
{
|
||||
_id: '6397614322515597713',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar1"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId-all',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId2',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc2',
|
||||
'doc3',
|
||||
'doc4',
|
||||
'doc5',
|
||||
'doc6',
|
||||
'doc7',
|
||||
'doc8',
|
||||
'doc9',
|
||||
'doc10',
|
||||
'doc1',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
{
|
||||
_id: '7273547236741116661',
|
||||
_source: {
|
||||
doc_id: 'doc0',
|
||||
workspace_id: 'workspaceId-search-query-match-ref_doc_id-for-manticoresearch',
|
||||
},
|
||||
fields: {
|
||||
additional: [
|
||||
'{"foo": "bar3"}',
|
||||
],
|
||||
block_id: [
|
||||
'blockId4',
|
||||
],
|
||||
doc_id: [
|
||||
'doc0',
|
||||
],
|
||||
parent_block_id: [
|
||||
'parentBlockId4',
|
||||
],
|
||||
parent_flavour: [
|
||||
'affine:database',
|
||||
],
|
||||
ref_doc_id: [
|
||||
'doc10',
|
||||
],
|
||||
},
|
||||
highlights: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
## should aggregate query return top score first
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
{
|
||||
count: 1,
|
||||
hits: [
|
||||
{
|
||||
_id: '6281444972018276017',
|
||||
_source: {
|
||||
doc_id: 'doc-0',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-0',
|
||||
],
|
||||
flavour: [
|
||||
'affine:page',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'<b>0.15 - week.1</b> 进度',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
key: 'doc-0',
|
||||
},
|
||||
{
|
||||
count: 2,
|
||||
hits: [
|
||||
{
|
||||
_id: '2160976319205307295',
|
||||
_source: {
|
||||
doc_id: 'doc-10',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-10-1',
|
||||
],
|
||||
flavour: [
|
||||
'affine:paragraph',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'Example <b>1</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: '2160977418716935506',
|
||||
_source: {
|
||||
doc_id: 'doc-10',
|
||||
workspace_id: 'aggregate-test-workspace-top-score-max-first',
|
||||
},
|
||||
fields: {
|
||||
block_id: [
|
||||
'block-10-2',
|
||||
],
|
||||
flavour: [
|
||||
'affine:paragraph',
|
||||
],
|
||||
},
|
||||
highlights: {
|
||||
content: [
|
||||
'Single substitution format <b>1</b>',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
key: 'doc-10',
|
||||
},
|
||||
]
|
||||
|
||||
## should parse es query term work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
term: {
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
term: {
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 3
|
||||
|
||||
{
|
||||
match: {
|
||||
flavour_indexed: {
|
||||
boost: 1.5,
|
||||
query: 'affine:page',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 4
|
||||
|
||||
{
|
||||
match: {
|
||||
doc_id: {
|
||||
boost: 1.5,
|
||||
query: 'docId1',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
## should parse es query with custom term mapping field work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
equals: {
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
},
|
||||
{
|
||||
equals: {
|
||||
doc_id: 'docId1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
bool: {
|
||||
must: {
|
||||
equals: {
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 3
|
||||
|
||||
{
|
||||
equals: {
|
||||
workspace_id: 'workspaceId1',
|
||||
},
|
||||
}
|
||||
|
||||
## should parse es query exists work
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
exists: {
|
||||
field: 'parent_block_id_indexed',
|
||||
},
|
||||
}
|
||||
|
||||
> Snapshot 2
|
||||
|
||||
{
|
||||
exists: {
|
||||
field: 'ref_doc_id',
|
||||
},
|
||||
}
|
||||
BIN
Binary file not shown.
+1584
File diff suppressed because it is too large
Load Diff
+1481
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export enum SearchProviderType {
|
||||
Manticoresearch = 'manticoresearch',
|
||||
Elasticsearch = 'elasticsearch',
|
||||
}
|
||||
|
||||
const SearchProviderTypeSchema = z.nativeEnum(SearchProviderType);
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
indexer: {
|
||||
enabled: boolean;
|
||||
provider: {
|
||||
type: SearchProviderType;
|
||||
endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('indexer', {
|
||||
enabled: {
|
||||
desc: 'Enable indexer plugin',
|
||||
default: true,
|
||||
},
|
||||
'provider.type': {
|
||||
desc: 'Indexer search service provider name',
|
||||
default: SearchProviderType.Manticoresearch,
|
||||
shape: SearchProviderTypeSchema,
|
||||
env: ['AFFINE_INDEXER_SEARCH_PROVIDER', 'string'],
|
||||
},
|
||||
'provider.endpoint': {
|
||||
desc: 'Indexer search service endpoint',
|
||||
default: 'http://localhost:9308',
|
||||
env: ['AFFINE_INDEXER_SEARCH_ENDPOINT', 'string'],
|
||||
validate: val => {
|
||||
// allow to be nullable and empty string
|
||||
if (!val) {
|
||||
return { success: true, data: val };
|
||||
}
|
||||
|
||||
return z.string().url().safeParse(val);
|
||||
},
|
||||
},
|
||||
'provider.username': {
|
||||
desc: 'Indexer search service auth username, if not set, basic auth will be disabled. Optional for elasticsearch',
|
||||
link: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_USERNAME', 'string'],
|
||||
},
|
||||
'provider.password': {
|
||||
desc: 'Indexer search service auth password, if not set, basic auth will be disabled. Optional for elasticsearch',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_PASSWORD', 'string'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SearchProviderNotFound } from '../../base';
|
||||
import { ServerFeature, ServerService } from '../../core';
|
||||
import { SearchProviderType } from './config';
|
||||
import type { SearchProvider } from './providers/def';
|
||||
|
||||
@Injectable()
|
||||
export class SearchProviderFactory {
|
||||
constructor(private readonly server: ServerService) {}
|
||||
|
||||
private readonly logger = new Logger(SearchProviderFactory.name);
|
||||
readonly #providers = new Map<SearchProviderType, SearchProvider>();
|
||||
#providerType: SearchProviderType | undefined;
|
||||
|
||||
get(): SearchProvider {
|
||||
const provider =
|
||||
this.#providerType && this.#providers.get(this.#providerType);
|
||||
if (!provider) {
|
||||
throw new SearchProviderNotFound();
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
register(provider: SearchProvider) {
|
||||
if (this.#providers.has(provider.type)) {
|
||||
return;
|
||||
}
|
||||
this.#providerType = provider.type;
|
||||
this.#providers.set(provider.type, provider);
|
||||
this.logger.log(`Search provider [${provider.type}] registered.`);
|
||||
this.server.enableFeature(ServerFeature.Indexer);
|
||||
}
|
||||
|
||||
unregister(provider: SearchProvider) {
|
||||
if (!this.#providers.has(provider.type)) {
|
||||
return;
|
||||
}
|
||||
this.#providers.delete(provider.type);
|
||||
this.logger.log(`Search provider [${provider.type}] unregistered.`);
|
||||
if (this.#providers.size === 0) {
|
||||
this.server.disableFeature(ServerFeature.Indexer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServerConfigModule } from '../../core/config';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { SearchProviderFactory } from './factory';
|
||||
import { SearchProviders } from './providers';
|
||||
import { IndexerResolver } from './resolver';
|
||||
import { IndexerService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [ServerConfigModule, PermissionModule],
|
||||
providers: [
|
||||
IndexerResolver,
|
||||
IndexerService,
|
||||
SearchProviderFactory,
|
||||
...SearchProviders,
|
||||
],
|
||||
exports: [IndexerService, SearchProviderFactory],
|
||||
})
|
||||
export class IndexerModule {}
|
||||
|
||||
export { IndexerService };
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, OnEvent } from '../../../base';
|
||||
import { SearchProviderType } from '../config';
|
||||
import { SearchProviderFactory } from '../factory';
|
||||
import { SearchTable } from '../tables';
|
||||
|
||||
export interface SearchNode {
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: Record<string, unknown>;
|
||||
fields: Record<string, unknown[]>;
|
||||
highlights?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
took: number;
|
||||
timedOut: boolean;
|
||||
total: number;
|
||||
nodes: SearchNode[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface AggregateBucket {
|
||||
key: string;
|
||||
count: number;
|
||||
hits: {
|
||||
nodes: SearchNode[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AggregateResult {
|
||||
took: number;
|
||||
timedOut: boolean;
|
||||
total: number;
|
||||
buckets: AggregateBucket[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface BaseQueryDSL {
|
||||
_source: string[];
|
||||
sort: unknown[];
|
||||
query: Record<string, any>;
|
||||
size?: number;
|
||||
from?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface HighlightDSL {
|
||||
pre_tags: string[];
|
||||
post_tags: string[];
|
||||
}
|
||||
|
||||
export interface SearchQueryDSL extends BaseQueryDSL {
|
||||
fields: string[];
|
||||
highlight?: {
|
||||
fields: Record<string, HighlightDSL>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TopHitsDSL
|
||||
extends Omit<SearchQueryDSL, 'query' | 'sort' | 'from' | 'cursor'> {}
|
||||
|
||||
export interface AggregateQueryDSL extends BaseQueryDSL {
|
||||
aggs: {
|
||||
result: {
|
||||
terms: {
|
||||
field: string;
|
||||
size?: number;
|
||||
order: {
|
||||
max_score: 'desc';
|
||||
};
|
||||
};
|
||||
aggs: {
|
||||
max_score: {
|
||||
max: {
|
||||
script: {
|
||||
source: '_score';
|
||||
};
|
||||
};
|
||||
};
|
||||
result: {
|
||||
top_hits: TopHitsDSL;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OperationOptions {
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export abstract class SearchProvider {
|
||||
abstract type: SearchProviderType;
|
||||
/**
|
||||
* Create a new search index table.
|
||||
*/
|
||||
abstract createTable(table: SearchTable, mapping: string): Promise<void>;
|
||||
/**
|
||||
* Search documents from the search index table.
|
||||
*/
|
||||
abstract search(
|
||||
table: SearchTable,
|
||||
dsl: SearchQueryDSL
|
||||
): Promise<SearchResult>;
|
||||
/**
|
||||
* Aggregate documents from the search index table.
|
||||
*/
|
||||
abstract aggregate(
|
||||
table: SearchTable,
|
||||
dsl: AggregateQueryDSL
|
||||
): Promise<AggregateResult>;
|
||||
/**
|
||||
* Write documents to the search index table.
|
||||
* If the document already exists, it will be replaced.
|
||||
* If the document does not exist, it will be created.
|
||||
*/
|
||||
abstract write(
|
||||
table: SearchTable,
|
||||
documents: Record<string, unknown>[],
|
||||
options?: OperationOptions
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Delete documents from the search index table.
|
||||
*/
|
||||
abstract deleteByQuery(
|
||||
table: SearchTable,
|
||||
query: Record<string, any>,
|
||||
options?: OperationOptions
|
||||
): Promise<void>;
|
||||
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
@Inject() private readonly factory!: SearchProviderFactory;
|
||||
@Inject() private readonly AFFiNEConfig!: Config;
|
||||
|
||||
protected get config() {
|
||||
return this.AFFiNEConfig.indexer;
|
||||
}
|
||||
|
||||
protected get configured() {
|
||||
return this.config.enabled && this.config.provider.type === this.type;
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
onConfigInit() {
|
||||
this.setup();
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
onConfigUpdated(event: Events['config.changed']) {
|
||||
if ('indexer' in event.updates) {
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
protected setup() {
|
||||
if (this.configured) {
|
||||
this.factory.register(this);
|
||||
} else {
|
||||
this.factory.unregister(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
InvalidSearchProviderRequest,
|
||||
} from '../../../base';
|
||||
import { SearchProviderType } from '../config';
|
||||
import { SearchTable, SearchTableUniqueId } from '../tables';
|
||||
import {
|
||||
AggregateQueryDSL,
|
||||
AggregateResult,
|
||||
OperationOptions,
|
||||
SearchProvider,
|
||||
SearchQueryDSL,
|
||||
SearchResult,
|
||||
} from './def';
|
||||
|
||||
interface ESSearchResponse {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
};
|
||||
hits: {
|
||||
_index: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: Record<string, unknown>;
|
||||
fields: Record<string, unknown[]>;
|
||||
highlight?: Record<string, string[]>;
|
||||
sort: unknown[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ESAggregateResponse extends ESSearchResponse {
|
||||
aggregations: {
|
||||
result: {
|
||||
buckets: {
|
||||
key: string;
|
||||
doc_count: number;
|
||||
result: {
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
};
|
||||
max_score: number;
|
||||
hits: {
|
||||
_index: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: Record<string, unknown>;
|
||||
fields: Record<string, unknown[]>;
|
||||
highlight?: Record<string, string[]>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ElasticsearchProvider extends SearchProvider {
|
||||
type = SearchProviderType.Elasticsearch;
|
||||
|
||||
/**
|
||||
* @see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-create
|
||||
*/
|
||||
override async createTable(
|
||||
table: SearchTable,
|
||||
mapping: string
|
||||
): Promise<void> {
|
||||
const url = `${this.config.provider.endpoint}/${table}`;
|
||||
try {
|
||||
const result = await this.request('PUT', url, mapping);
|
||||
this.logger.log(
|
||||
`created table ${table}, result: ${JSON.stringify(result)}`
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof InvalidSearchProviderRequest &&
|
||||
err.data.type === 'resource_already_exists_exception'
|
||||
) {
|
||||
this.logger.debug(`table ${table} already exists`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async write(
|
||||
table: SearchTable,
|
||||
documents: Record<string, unknown>[],
|
||||
options?: OperationOptions
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
const records: string[] = [];
|
||||
for (const document of documents) {
|
||||
// @ts-expect-error ignore document type check
|
||||
const id = SearchTableUniqueId[table](document);
|
||||
records.push(
|
||||
JSON.stringify({
|
||||
index: {
|
||||
_index: table,
|
||||
_id: id,
|
||||
},
|
||||
})
|
||||
);
|
||||
records.push(JSON.stringify(document));
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
if (options?.refresh) {
|
||||
query.refresh = 'true';
|
||||
}
|
||||
await this.requestBulk(table, records, query);
|
||||
this.logger.debug(
|
||||
`wrote ${documents.length} documents to ${table} in ${Date.now() - start}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-delete-by-query
|
||||
*/
|
||||
override async deleteByQuery<T extends SearchTable>(
|
||||
table: T,
|
||||
query: Record<string, any>,
|
||||
options?: OperationOptions
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
const url = new URL(
|
||||
`${this.config.provider.endpoint}/${table}/_delete_by_query`
|
||||
);
|
||||
if (options?.refresh) {
|
||||
url.searchParams.set('refresh', 'true');
|
||||
}
|
||||
const result = await this.request(
|
||||
'POST',
|
||||
url.toString(),
|
||||
JSON.stringify({ query })
|
||||
);
|
||||
this.logger.debug(
|
||||
`deleted by query ${table} ${JSON.stringify(query)} in ${Date.now() - start}ms, result: ${JSON.stringify(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
override async search(
|
||||
table: SearchTable,
|
||||
dsl: SearchQueryDSL
|
||||
): Promise<SearchResult> {
|
||||
const body = this.#convertToSearchBody(dsl);
|
||||
const data = (await this.requestSearch(table, body)) as ESSearchResponse;
|
||||
return {
|
||||
took: data.took,
|
||||
timedOut: data.timed_out,
|
||||
total: data.hits.total.value,
|
||||
nextCursor: this.#encodeCursor(data.hits.hits.at(-1)?.sort),
|
||||
nodes: data.hits.hits.map(hit => ({
|
||||
_id: hit._id,
|
||||
_score: hit._score,
|
||||
_source: hit._source,
|
||||
fields: hit.fields,
|
||||
highlights: hit.highlight,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
override async aggregate(
|
||||
table: SearchTable,
|
||||
dsl: AggregateQueryDSL
|
||||
): Promise<AggregateResult> {
|
||||
const body = this.#convertToSearchBody(dsl);
|
||||
const data = (await this.requestSearch(table, body)) as ESAggregateResponse;
|
||||
const buckets = data.aggregations.result.buckets;
|
||||
return {
|
||||
took: data.took,
|
||||
timedOut: data.timed_out,
|
||||
total: data.hits.total.value,
|
||||
nextCursor: this.#encodeCursor(data.hits.hits.at(-1)?.sort),
|
||||
buckets: buckets.map(bucket => ({
|
||||
key: bucket.key,
|
||||
count: bucket.doc_count,
|
||||
hits: {
|
||||
nodes: bucket.result.hits.hits.map(hit => ({
|
||||
_id: hit._id,
|
||||
_score: hit._score,
|
||||
_source: hit._source,
|
||||
fields: hit.fields,
|
||||
highlights: hit.highlight,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
protected async requestSearch(table: SearchTable, body: Record<string, any>) {
|
||||
const url = `${this.config.provider.endpoint}/${table}/_search`;
|
||||
const jsonBody = JSON.stringify(body);
|
||||
const start = Date.now();
|
||||
try {
|
||||
return await this.request('POST', url, jsonBody);
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
// log slow search
|
||||
if (duration > 1000) {
|
||||
this.logger.warn(
|
||||
`Slow search on ${table} in ${duration}ms, DSL: ${jsonBody}`
|
||||
);
|
||||
} else {
|
||||
this.logger.verbose(
|
||||
`search ${table} in ${duration}ms, DSL: ${jsonBody}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.elastic.co/docs/api/doc/elasticsearch-serverless/operation/operation-bulk-2
|
||||
*/
|
||||
protected async requestBulk(
|
||||
table: SearchTable,
|
||||
records: string[],
|
||||
query?: Record<string, string>
|
||||
) {
|
||||
const url = new URL(`${this.config.provider.endpoint}/${table}/_bulk`);
|
||||
if (query) {
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
return await this.request(
|
||||
'POST',
|
||||
url.toString(),
|
||||
records.join('\n') + '\n',
|
||||
'application/x-ndjson'
|
||||
);
|
||||
}
|
||||
|
||||
protected async request(
|
||||
method: 'POST' | 'PUT',
|
||||
url: string,
|
||||
body: string,
|
||||
contentType = 'application/json'
|
||||
) {
|
||||
const headers = {
|
||||
'Content-Type': contentType,
|
||||
} as Record<string, string>;
|
||||
if (this.config.provider.password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
// handle error, status >= 400
|
||||
// {
|
||||
// "error": {
|
||||
// "root_cause": [
|
||||
// {
|
||||
// "type": "illegal_argument_exception",
|
||||
// "reason": "The bulk request must be terminated by a newline [\\n]"
|
||||
// }
|
||||
// ],
|
||||
// "type": "illegal_argument_exception",
|
||||
// "reason": "The bulk request must be terminated by a newline [\\n]"
|
||||
// },
|
||||
// "status": 400
|
||||
// }
|
||||
if (response.status >= 500) {
|
||||
this.logger.error(
|
||||
`request error, url: ${url}, body: ${body}, response status: ${response.status}, response body: ${JSON.stringify(data, null, 2)}`
|
||||
);
|
||||
throw new InternalServerError();
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
this.logger.warn(
|
||||
`request failed, url: ${url}, body: ${body}, response status: ${response.status}, response body: ${JSON.stringify(data, null, 2)}`
|
||||
);
|
||||
const errorData = data as {
|
||||
error: { type: string; reason: string } | string;
|
||||
};
|
||||
let reason = '';
|
||||
let type = '';
|
||||
if (typeof errorData.error === 'string') {
|
||||
reason = errorData.error;
|
||||
} else {
|
||||
reason = errorData.error.reason;
|
||||
type = errorData.error.type;
|
||||
}
|
||||
throw new InvalidSearchProviderRequest({
|
||||
reason,
|
||||
type,
|
||||
});
|
||||
}
|
||||
this.logger.verbose(
|
||||
`request ${method} ${url}, body: ${body}, response status: ${response.status}, response body: ${JSON.stringify(data)}`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
#convertToSearchBody(dsl: SearchQueryDSL | AggregateQueryDSL) {
|
||||
const data: Record<string, any> = {
|
||||
...dsl,
|
||||
};
|
||||
if (dsl.cursor) {
|
||||
data.cursor = undefined;
|
||||
data.search_after = this.#decodeCursor(dsl.cursor);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
#decodeCursor(cursor: string) {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
|
||||
}
|
||||
|
||||
#encodeCursor(cursor?: unknown[]) {
|
||||
return cursor
|
||||
? Buffer.from(JSON.stringify(cursor)).toString('base64')
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ElasticsearchProvider } from './elasticsearch';
|
||||
import { ManticoresearchProvider } from './manticoresearch';
|
||||
|
||||
export const SearchProviders = [ManticoresearchProvider, ElasticsearchProvider];
|
||||
|
||||
export * from './def';
|
||||
export * from './elasticsearch';
|
||||
export * from './manticoresearch';
|
||||
@@ -0,0 +1,403 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { omit } from 'lodash-es';
|
||||
|
||||
import { InternalServerError } from '../../../base';
|
||||
import { SearchProviderType } from '../config';
|
||||
import { SearchTable } from '../tables';
|
||||
import {
|
||||
AggregateQueryDSL,
|
||||
AggregateResult,
|
||||
HighlightDSL,
|
||||
OperationOptions,
|
||||
SearchNode,
|
||||
SearchQueryDSL,
|
||||
SearchResult,
|
||||
} from './def';
|
||||
import { ElasticsearchProvider } from './elasticsearch';
|
||||
|
||||
interface MSSearchResponse {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
hits: {
|
||||
total: number;
|
||||
hits: {
|
||||
_index: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: Record<string, unknown>;
|
||||
highlight?: Record<string, string[]>;
|
||||
sort: unknown[];
|
||||
}[];
|
||||
};
|
||||
scroll: string;
|
||||
}
|
||||
|
||||
const SupportIndexedAttributes = [
|
||||
'flavour',
|
||||
'parent_flavour',
|
||||
'parent_block_id',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ManticoresearchProvider extends ElasticsearchProvider {
|
||||
override type = SearchProviderType.Manticoresearch;
|
||||
|
||||
override async createTable(
|
||||
table: SearchTable,
|
||||
mapping: string
|
||||
): Promise<void> {
|
||||
const url = `${this.config.provider.endpoint}/cli`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: mapping,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
// manticoresearch cli response is not json, so we need to handle it manually
|
||||
const text = (await response.text()).trim();
|
||||
if (!response.ok) {
|
||||
this.logger.error(`failed to create table ${table}, response: ${text}`);
|
||||
throw new InternalServerError();
|
||||
}
|
||||
this.logger.log(`created table ${table}, response: ${text}`);
|
||||
}
|
||||
|
||||
override async write(
|
||||
table: SearchTable,
|
||||
documents: Record<string, unknown>[],
|
||||
options?: OperationOptions
|
||||
): Promise<void> {
|
||||
if (table === SearchTable.block) {
|
||||
documents = documents.map(document => ({
|
||||
...document,
|
||||
// convert content `string[]` to `string`
|
||||
// because manticoresearch full text search does not support `string[]`
|
||||
content: Array.isArray(document.content)
|
||||
? document.content.join(' ')
|
||||
: document.content,
|
||||
// convert one item array to string in `blob`, `ref`, `ref_doc_id`
|
||||
blob: this.#formatArrayValue(document.blob),
|
||||
ref: this.#formatArrayValue(document.ref),
|
||||
ref_doc_id: this.#formatArrayValue(document.ref_doc_id),
|
||||
// add extra indexed attributes
|
||||
...SupportIndexedAttributes.reduce(
|
||||
(acc, attribute) => {
|
||||
acc[`${attribute}_indexed`] = document[attribute];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
),
|
||||
}));
|
||||
}
|
||||
await super.write(table, documents, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://manual.manticoresearch.com/Data_creation_and_modification/Deleting_documents?static=true&client=JSON#Deleting-documents
|
||||
*/
|
||||
override async deleteByQuery<T extends SearchTable>(
|
||||
table: T,
|
||||
query: Record<string, any>,
|
||||
options?: OperationOptions
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
const url = new URL(`${this.config.provider.endpoint}/delete`);
|
||||
if (options?.refresh) {
|
||||
url.searchParams.set('refresh', 'true');
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
table,
|
||||
// term not work on delete query, so we need to use equals instead
|
||||
query: this.parseESQuery(query, { termMappingField: 'equals' }),
|
||||
});
|
||||
const result = await this.request('POST', url.toString(), body);
|
||||
this.logger.debug(
|
||||
`deleted by query ${body} in ${Date.now() - start}ms, result: ${JSON.stringify(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
override async search(
|
||||
table: SearchTable,
|
||||
dsl: SearchQueryDSL
|
||||
): Promise<SearchResult> {
|
||||
const body = this.#convertToSearchBody(dsl);
|
||||
const data = (await this.requestSearch(table, body)) as MSSearchResponse;
|
||||
return {
|
||||
took: data.took,
|
||||
timedOut: data.timed_out,
|
||||
total: data.hits.total,
|
||||
nextCursor: data.scroll,
|
||||
nodes: data.hits.hits.map(hit => ({
|
||||
_id: hit._id,
|
||||
_score: hit._score,
|
||||
_source: this.#formatSource(dsl._source, hit._source),
|
||||
fields: this.#formatFieldsFromSource(dsl.fields, hit._source),
|
||||
highlights: this.#formatHighlights(
|
||||
dsl.highlight?.fields,
|
||||
hit.highlight
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
override async aggregate(
|
||||
table: SearchTable,
|
||||
dsl: AggregateQueryDSL
|
||||
): Promise<AggregateResult> {
|
||||
const aggs = dsl.aggs;
|
||||
const topHits = aggs.result.aggs.result.top_hits;
|
||||
const groupByField = aggs.result.terms.field;
|
||||
const searchDSL = {
|
||||
...omit(dsl, 'aggs'),
|
||||
// add groupByField to fields if not already in
|
||||
fields: topHits.fields.includes(groupByField)
|
||||
? topHits.fields
|
||||
: [...topHits.fields, groupByField],
|
||||
highlight: topHits.highlight,
|
||||
};
|
||||
const body = this.#convertToSearchBody(searchDSL);
|
||||
const data = (await this.requestSearch(table, body)) as MSSearchResponse;
|
||||
|
||||
// calculate the aggregate buckets
|
||||
const bucketsMap = new Map<string, SearchNode[]>();
|
||||
for (const hit of data.hits.hits) {
|
||||
const key = hit._source[groupByField] as string;
|
||||
const node = {
|
||||
_id: hit._id,
|
||||
_score: hit._score,
|
||||
_source: this.#formatSource(topHits._source, hit._source),
|
||||
fields: this.#formatFieldsFromSource(topHits.fields, hit._source),
|
||||
highlights: this.#formatHighlights(
|
||||
topHits.highlight?.fields,
|
||||
hit.highlight
|
||||
),
|
||||
};
|
||||
if (bucketsMap.has(key)) {
|
||||
bucketsMap.get(key)?.push(node);
|
||||
} else {
|
||||
bucketsMap.set(key, [node]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
took: data.took,
|
||||
timedOut: data.timed_out,
|
||||
total: data.hits.total,
|
||||
nextCursor: data.scroll,
|
||||
buckets: Array.from(bucketsMap.entries()).map(([key, nodes]) => ({
|
||||
key,
|
||||
count: nodes.length,
|
||||
hits: {
|
||||
nodes: topHits.size ? nodes.slice(0, topHits.size) : nodes,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
#convertToSearchBody(dsl: SearchQueryDSL) {
|
||||
const data: Record<string, any> = {
|
||||
...dsl,
|
||||
query: this.parseESQuery(dsl.query),
|
||||
fields: undefined,
|
||||
_source: [...new Set([...dsl._source, ...dsl.fields])],
|
||||
};
|
||||
|
||||
// https://manual.manticoresearch.com/Searching/Pagination#Pagination-of-search-results
|
||||
// use scroll
|
||||
if (dsl.cursor) {
|
||||
data.cursor = undefined;
|
||||
data.options = {
|
||||
scroll: dsl.cursor,
|
||||
};
|
||||
} else {
|
||||
data.options = {
|
||||
scroll: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if highlight provided, add all fields to highlight
|
||||
// "highlight":{"fields":{"title":{"pre_tags":["<b>"],"post_tags":["</b>"]}}
|
||||
// to
|
||||
// "highlight":{"pre_tags":["<b>"],"post_tags":["</b>"]}
|
||||
if (dsl.highlight) {
|
||||
const firstOptions = Object.values(dsl.highlight.fields)[0];
|
||||
data.highlight = firstOptions;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private parseESQuery(
|
||||
query: Record<string, any>,
|
||||
options?: {
|
||||
termMappingField?: string;
|
||||
parentNodes?: Record<string, any>[];
|
||||
}
|
||||
) {
|
||||
let node: Record<string, any> = {};
|
||||
if (query.bool) {
|
||||
node.bool = {};
|
||||
for (const occur in query.bool) {
|
||||
const conditions = query.bool[occur];
|
||||
if (Array.isArray(conditions)) {
|
||||
node.bool[occur] = [];
|
||||
// { must: [ { term: [Object] }, { bool: [Object] } ] }
|
||||
// {
|
||||
// must: [ { term: [Object] }, { term: [Object] }, { bool: [Object] } ]
|
||||
// }
|
||||
for (const item of conditions) {
|
||||
this.parseESQuery(item, {
|
||||
...options,
|
||||
parentNodes: node.bool[occur],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// {
|
||||
// must_not: { term: { doc_id: 'docId' } }
|
||||
// }
|
||||
node.bool[occur] = this.parseESQuery(conditions, {
|
||||
termMappingField: options?.termMappingField,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (query.term) {
|
||||
// {
|
||||
// term: {
|
||||
// workspace_id: {
|
||||
// value: 'workspaceId1'
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// term: {
|
||||
// workspace_id: 'workspaceId1'
|
||||
// }
|
||||
// }
|
||||
let termField = options?.termMappingField ?? 'term';
|
||||
let field = Object.keys(query.term)[0];
|
||||
let value = query.term[field];
|
||||
if (typeof value === 'object' && 'value' in value) {
|
||||
if ('boost' in value) {
|
||||
// {
|
||||
// term: {
|
||||
// flavour: {
|
||||
// value: 'affine:page',
|
||||
// boost: 1.5,
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// match: {
|
||||
// flavour_indexed: {
|
||||
// query: 'affine:page',
|
||||
// boost: 1.5,
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
if (SupportIndexedAttributes.includes(field)) {
|
||||
field = `${field}_indexed`;
|
||||
}
|
||||
termField = 'match';
|
||||
value = {
|
||||
query: value.value,
|
||||
boost: value.boost,
|
||||
};
|
||||
} else {
|
||||
value = value.value;
|
||||
}
|
||||
}
|
||||
node = {
|
||||
[termField]: {
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
} else if (query.exists) {
|
||||
let field = query.exists.field;
|
||||
if (SupportIndexedAttributes.includes(field)) {
|
||||
// override the field to indexed field
|
||||
field = `${field}_indexed`;
|
||||
}
|
||||
node = {
|
||||
...query,
|
||||
exists: {
|
||||
...query.exists,
|
||||
field,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
node = {
|
||||
...query,
|
||||
};
|
||||
}
|
||||
if (options?.parentNodes) {
|
||||
options.parentNodes.push(node);
|
||||
}
|
||||
// this.logger.verbose(`parsed es query ${JSON.stringify(query, null, 2)} to ${JSON.stringify(node, null, 2)}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fields from source to match the expected format for ManticoreSearch
|
||||
*/
|
||||
#formatFieldsFromSource(fields: string[], source: Record<string, unknown>) {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
let value = source[field];
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
// special handle `ref_doc_id`, `ref`, `blob` as string[]
|
||||
if (
|
||||
(field === 'ref_doc_id' || field === 'ref' || field === 'blob') &&
|
||||
typeof value === 'string' &&
|
||||
value.startsWith('["')
|
||||
) {
|
||||
//'["b5ed7e73-b792-4a80-8727-c009c5b50116","573ccd98-72be-4a43-9e75-fdc67231bcb4"]'
|
||||
// to
|
||||
// ['b5ed7e73-b792-4a80-8727-c009c5b50116', '573ccd98-72be-4a43-9e75-fdc67231bcb4']
|
||||
// or
|
||||
// '["{\"foo\": \"bar\"}","{\"foo\": \"baz\"}"]'
|
||||
// to
|
||||
// [{foo: 'bar'}, {foo: 'baz'}]
|
||||
value = JSON.parse(value as string);
|
||||
}
|
||||
acc[field] = Array.isArray(value) ? value : [value];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown[]>
|
||||
);
|
||||
}
|
||||
|
||||
#formatHighlights(
|
||||
highlightFields?: Record<string, HighlightDSL>,
|
||||
highlights?: Record<string, string[]>
|
||||
) {
|
||||
if (!highlightFields || !highlights) {
|
||||
return undefined;
|
||||
}
|
||||
return this.#formatFieldsFromSource(
|
||||
Object.keys(highlightFields),
|
||||
highlights
|
||||
);
|
||||
}
|
||||
|
||||
#formatSource(fields: string[], source: Record<string, unknown>) {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field] = source[field];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
#formatArrayValue(value: unknown | unknown[]) {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 1) {
|
||||
return value[0];
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { camelCase, chunk, mapKeys, snakeCase } from 'lodash-es';
|
||||
|
||||
import { InvalidIndexerInput, SearchProviderNotFound } from '../../base';
|
||||
import { SearchProviderType } from './config';
|
||||
import { SearchProviderFactory } from './factory';
|
||||
import {
|
||||
AggregateQueryDSL,
|
||||
BaseQueryDSL,
|
||||
HighlightDSL,
|
||||
OperationOptions,
|
||||
SearchNode,
|
||||
SearchProvider,
|
||||
SearchQueryDSL,
|
||||
TopHitsDSL,
|
||||
} from './providers';
|
||||
import {
|
||||
Block,
|
||||
blockMapping,
|
||||
BlockSchema,
|
||||
blockSQL,
|
||||
Doc,
|
||||
docMapping,
|
||||
DocSchema,
|
||||
docSQL,
|
||||
SearchTable,
|
||||
} from './tables';
|
||||
import {
|
||||
AggregateInput,
|
||||
SearchHighlight,
|
||||
SearchInput,
|
||||
SearchQuery,
|
||||
SearchQueryType,
|
||||
} from './types';
|
||||
|
||||
// always return these fields to check permission
|
||||
const DefaultSourceFields = ['workspace_id', 'doc_id'] as const;
|
||||
|
||||
export const SearchTableSorts = {
|
||||
[SearchProviderType.Elasticsearch]: {
|
||||
[SearchTable.block]: [
|
||||
'_score',
|
||||
{ updated_at: 'desc' },
|
||||
'doc_id',
|
||||
'block_id',
|
||||
],
|
||||
[SearchTable.doc]: ['_score', { updated_at: 'desc' }, 'doc_id'],
|
||||
},
|
||||
// add id to sort and make sure scroll can work on manticoresearch
|
||||
[SearchProviderType.Manticoresearch]: {
|
||||
[SearchTable.block]: ['_score', { updated_at: 'desc' }, 'id'],
|
||||
[SearchTable.doc]: ['_score', { updated_at: 'desc' }, 'id'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const SearchTableMappingStrings = {
|
||||
[SearchProviderType.Elasticsearch]: {
|
||||
[SearchTable.block]: JSON.stringify(blockMapping),
|
||||
[SearchTable.doc]: JSON.stringify(docMapping),
|
||||
},
|
||||
[SearchProviderType.Manticoresearch]: {
|
||||
[SearchTable.block]: blockSQL,
|
||||
[SearchTable.doc]: docSQL,
|
||||
},
|
||||
};
|
||||
|
||||
const SearchTableSchema = {
|
||||
[SearchTable.block]: BlockSchema,
|
||||
[SearchTable.doc]: DocSchema,
|
||||
};
|
||||
|
||||
const SupportFullTextSearchFields = {
|
||||
[SearchTable.block]: ['content'],
|
||||
[SearchTable.doc]: ['title'],
|
||||
};
|
||||
|
||||
const AllowAggregateFields = new Set(['docId', 'flavour']);
|
||||
|
||||
type SnakeToCamelCase<S extends string> =
|
||||
S extends `${infer Head}_${infer Tail}`
|
||||
? `${Head}${Capitalize<SnakeToCamelCase<Tail>>}`
|
||||
: S;
|
||||
type CamelizeKeys<T> = {
|
||||
[K in keyof T as SnakeToCamelCase<K & string>]: T[K];
|
||||
};
|
||||
export type UpsertDoc = CamelizeKeys<Doc>;
|
||||
export type UpsertBlock = CamelizeKeys<Block>;
|
||||
export type UpsertTypeByTable<T extends SearchTable> =
|
||||
T extends SearchTable.block ? UpsertBlock : UpsertDoc;
|
||||
|
||||
export interface SearchNodeWithMeta extends SearchNode {
|
||||
_source: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IndexerService {
|
||||
private readonly logger = new Logger(IndexerService.name);
|
||||
|
||||
constructor(private readonly factory: SearchProviderFactory) {}
|
||||
|
||||
async createTables() {
|
||||
let searchProvider: SearchProvider | undefined;
|
||||
try {
|
||||
searchProvider = this.factory.get();
|
||||
} catch (err) {
|
||||
if (err instanceof SearchProviderNotFound) {
|
||||
this.logger.debug('No search provider found, skip creating tables');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const mappings = SearchTableMappingStrings[searchProvider.type];
|
||||
for (const table of Object.keys(mappings) as SearchTable[]) {
|
||||
await searchProvider.createTable(table, mappings[table]);
|
||||
}
|
||||
}
|
||||
|
||||
async write<T extends SearchTable>(
|
||||
table: T,
|
||||
documents: UpsertTypeByTable<T>[],
|
||||
options?: OperationOptions
|
||||
) {
|
||||
const searchProvider = this.factory.get();
|
||||
const schema = SearchTableSchema[table];
|
||||
// slice documents to 1000 documents each time
|
||||
const documentsChunks = chunk(documents, 1000);
|
||||
for (const documentsChunk of documentsChunks) {
|
||||
await searchProvider.write(
|
||||
table,
|
||||
documentsChunk.map(d =>
|
||||
schema.parse(mapKeys(d, (_, key) => snakeCase(key)))
|
||||
),
|
||||
options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async search(input: SearchInput) {
|
||||
const searchProvider = this.factory.get();
|
||||
const dsl = this.parseInput(input);
|
||||
const result = await searchProvider.search(input.table, dsl);
|
||||
return {
|
||||
...result,
|
||||
nodes: this.#formatSearchNodes(result.nodes),
|
||||
};
|
||||
}
|
||||
|
||||
async aggregate(input: AggregateInput) {
|
||||
const searchProvider = this.factory.get();
|
||||
const dsl = this.parseInput(input);
|
||||
const result = await searchProvider.aggregate(input.table, dsl);
|
||||
for (const bucket of result.buckets) {
|
||||
bucket.hits = {
|
||||
...bucket.hits,
|
||||
nodes: this.#formatSearchNodes(bucket.hits.nodes),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteByQuery<T extends SearchTable>(
|
||||
table: T,
|
||||
query: SearchQuery,
|
||||
options?: OperationOptions
|
||||
) {
|
||||
const searchProvider = this.factory.get();
|
||||
const dsl = this.#parseQuery(table, query);
|
||||
await searchProvider.deleteByQuery(table, dsl, options);
|
||||
}
|
||||
|
||||
#formatSearchNodes(nodes: SearchNode[]) {
|
||||
return nodes.map(node => ({
|
||||
...node,
|
||||
fields: mapKeys(node.fields, (_, key) => camelCase(key)),
|
||||
highlights: node.highlights
|
||||
? mapKeys(node.highlights, (_, key) => camelCase(key))
|
||||
: undefined,
|
||||
_source: {
|
||||
workspaceId: node._source.workspace_id,
|
||||
docId: node._source.doc_id,
|
||||
},
|
||||
})) as SearchNodeWithMeta[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input to ES query DSL
|
||||
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
|
||||
*/
|
||||
parseInput<T extends SearchInput | AggregateInput>(
|
||||
input: T
|
||||
): T extends SearchInput ? SearchQueryDSL : AggregateQueryDSL {
|
||||
// common options
|
||||
const query = this.#parseQuery(input.table, input.query);
|
||||
const searchProvider = this.factory.get();
|
||||
const dsl: BaseQueryDSL = {
|
||||
_source: [...DefaultSourceFields],
|
||||
sort: [...SearchTableSorts[searchProvider.type][input.table]],
|
||||
query,
|
||||
};
|
||||
const pagination = input.options.pagination;
|
||||
if (pagination?.limit) {
|
||||
if (pagination.limit > 10000) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: 'limit must be less than 10000',
|
||||
});
|
||||
}
|
||||
dsl.size = pagination.limit;
|
||||
}
|
||||
if (pagination?.skip) {
|
||||
dsl.from = pagination.skip;
|
||||
}
|
||||
if (pagination?.cursor) {
|
||||
dsl.cursor = pagination.cursor;
|
||||
}
|
||||
|
||||
if ('fields' in input.options) {
|
||||
// for search input
|
||||
const searchDsl: SearchQueryDSL = {
|
||||
...dsl,
|
||||
fields: input.options.fields.map(snakeCase),
|
||||
};
|
||||
if (input.options.highlights) {
|
||||
searchDsl.highlight = this.#parseHighlights(input.options.highlights);
|
||||
}
|
||||
// @ts-expect-error should be SearchQueryDSL
|
||||
return searchDsl;
|
||||
}
|
||||
|
||||
if ('field' in input) {
|
||||
// for aggregate input
|
||||
if (!AllowAggregateFields.has(input.field)) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: `aggregate field "${input.field}" is not allowed`,
|
||||
});
|
||||
}
|
||||
|
||||
// input: {
|
||||
// field: 'docId',
|
||||
// options: {
|
||||
// hits: {
|
||||
// fields: [...],
|
||||
// highlights: [...],
|
||||
// pagination: {
|
||||
// limit: 5,
|
||||
// },
|
||||
// },
|
||||
// pagination: {
|
||||
// limit: 100,
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// to
|
||||
// "aggs": {
|
||||
// "result": {
|
||||
// "terms": {
|
||||
// "field": "doc_id",
|
||||
// "size": 100,
|
||||
// "order": {
|
||||
// "max_score": "desc"
|
||||
// }
|
||||
// },
|
||||
// "aggs": {
|
||||
// "max_score": {
|
||||
// "max": {
|
||||
// "script": {
|
||||
// "source": "_score"
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// "result": {
|
||||
// "top_hits": {
|
||||
// "_source": false,
|
||||
// "fields": [...],
|
||||
// "highlights": [...],
|
||||
// "size": 5
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
const topHits: TopHitsDSL = {
|
||||
_source: [...DefaultSourceFields],
|
||||
fields: input.options.hits.fields.map(snakeCase),
|
||||
};
|
||||
if (input.options.hits.pagination?.limit) {
|
||||
topHits.size = input.options.hits.pagination.limit;
|
||||
}
|
||||
if (input.options.hits.highlights) {
|
||||
topHits.highlight = this.#parseHighlights(
|
||||
input.options.hits.highlights
|
||||
);
|
||||
}
|
||||
const aggregateDsl: AggregateQueryDSL = {
|
||||
...dsl,
|
||||
aggs: {
|
||||
result: {
|
||||
terms: {
|
||||
field: snakeCase(input.field),
|
||||
size: dsl.size,
|
||||
order: {
|
||||
max_score: 'desc',
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
max_score: {
|
||||
max: {
|
||||
script: {
|
||||
source: '_score',
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
// https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-top-hits-aggregation
|
||||
top_hits: topHits,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// @ts-expect-error should be AggregateQueryDSL
|
||||
return aggregateDsl;
|
||||
}
|
||||
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"field" or "fields" is required',
|
||||
});
|
||||
}
|
||||
|
||||
#parseQuery(
|
||||
table: SearchTable,
|
||||
query: SearchQuery,
|
||||
parentNodes?: unknown[]
|
||||
): Record<string, any> {
|
||||
if (query.type === SearchQueryType.match) {
|
||||
// required field and match
|
||||
if (!query.field) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"field" is required in match query',
|
||||
});
|
||||
}
|
||||
if (!query.match) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"match" is required in match query',
|
||||
});
|
||||
}
|
||||
|
||||
// {
|
||||
// type: 'match',
|
||||
// field: 'content',
|
||||
// match: keyword,
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// match: {
|
||||
// content: {
|
||||
// query: keyword
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// or
|
||||
// {
|
||||
// type: 'match',
|
||||
// field: 'refDocId',
|
||||
// match: docId,
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// term: {
|
||||
// ref_doc_id: {
|
||||
// value: docId
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
const field = snakeCase(query.field);
|
||||
const isFullTextField = SupportFullTextSearchFields[table].includes(
|
||||
query.field
|
||||
);
|
||||
const op = isFullTextField ? 'match' : 'term';
|
||||
const key = isFullTextField ? 'query' : 'value';
|
||||
const dsl = {
|
||||
[op]: {
|
||||
[field]: {
|
||||
[key]: query.match,
|
||||
...(typeof query.boost === 'number' && { boost: query.boost }),
|
||||
},
|
||||
},
|
||||
};
|
||||
if (parentNodes) {
|
||||
parentNodes.push(dsl);
|
||||
}
|
||||
return dsl;
|
||||
}
|
||||
if (query.type === SearchQueryType.boolean) {
|
||||
// required occur and queries
|
||||
if (!query.occur) {
|
||||
this.logger.debug(`query: ${JSON.stringify(query, null, 2)}`);
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"occur" is required in boolean query',
|
||||
});
|
||||
}
|
||||
if (!query.queries) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"queries" is required in boolean query',
|
||||
});
|
||||
}
|
||||
|
||||
// {
|
||||
// type: 'boolean',
|
||||
// occur: 'must_not',
|
||||
// queries: [
|
||||
// {
|
||||
// type: 'match',
|
||||
// field: 'docId',
|
||||
// match: 'docId1',
|
||||
// },
|
||||
// ],
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// bool: {
|
||||
// must_not: [
|
||||
// {
|
||||
// match: { doc_id: { query: 'docId1' } }
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// }
|
||||
const nodes: unknown[] = [];
|
||||
const dsl: Record<string, any> = {
|
||||
bool: {
|
||||
[query.occur]: nodes,
|
||||
...(typeof query.boost === 'number' && { boost: query.boost }),
|
||||
},
|
||||
};
|
||||
for (const subQuery of query.queries) {
|
||||
this.#parseQuery(table, subQuery, nodes);
|
||||
}
|
||||
if (parentNodes) {
|
||||
parentNodes.push(dsl);
|
||||
}
|
||||
return dsl;
|
||||
}
|
||||
if (query.type === SearchQueryType.exists) {
|
||||
// required field
|
||||
if (!query.field) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"field" is required in exists query',
|
||||
});
|
||||
}
|
||||
|
||||
// {
|
||||
// type: 'exists',
|
||||
// field: 'refDocId',
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// exists: {
|
||||
// field: 'ref_doc_id',
|
||||
// },
|
||||
// }
|
||||
const dsl = {
|
||||
exists: {
|
||||
field: snakeCase(query.field),
|
||||
...(typeof query.boost === 'number' && { boost: query.boost }),
|
||||
},
|
||||
};
|
||||
if (parentNodes) {
|
||||
parentNodes.push(dsl);
|
||||
}
|
||||
return dsl;
|
||||
}
|
||||
if (query.type === SearchQueryType.all) {
|
||||
// {
|
||||
// type: 'all'
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// match_all: {},
|
||||
// }
|
||||
const dsl = {
|
||||
match_all: {
|
||||
...(typeof query.boost === 'number' && { boost: query.boost }),
|
||||
},
|
||||
};
|
||||
if (parentNodes) {
|
||||
parentNodes.push(dsl);
|
||||
}
|
||||
return dsl;
|
||||
}
|
||||
if (query.type === SearchQueryType.boost) {
|
||||
// required query and boost
|
||||
if (!query.query) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"query" is required in boost query',
|
||||
});
|
||||
}
|
||||
if (typeof query.boost !== 'number') {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: '"boost" is required in boost query',
|
||||
});
|
||||
}
|
||||
|
||||
// {
|
||||
// type: 'boost',
|
||||
// boost: 1.5,
|
||||
// query: {
|
||||
// type: 'match',
|
||||
// field: 'flavour',
|
||||
// match: 'affine:page',
|
||||
// },
|
||||
// }
|
||||
// to
|
||||
// {
|
||||
// "match": {
|
||||
// "flavour": {
|
||||
// "query": "affine:page",
|
||||
// "boost": 1.5
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
return this.#parseQuery(
|
||||
table,
|
||||
{
|
||||
...query.query,
|
||||
boost: query.boost,
|
||||
},
|
||||
parentNodes
|
||||
);
|
||||
}
|
||||
throw new InvalidIndexerInput({
|
||||
reason: `unsupported query type: ${query.type}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse highlights to ES DSL
|
||||
* @see https://www.elastic.co/docs/reference/elasticsearch/rest-apis/highlighting
|
||||
*/
|
||||
#parseHighlights(highlights: SearchHighlight[]) {
|
||||
// [
|
||||
// {
|
||||
// field: 'content',
|
||||
// before: '<b>',
|
||||
// end: '</b>',
|
||||
// },
|
||||
// ]
|
||||
// to
|
||||
// {
|
||||
// fields: {
|
||||
// content: {
|
||||
// pre_tags: ['<b>'],
|
||||
// post_tags: ['</b>'],
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
const fields = highlights.reduce(
|
||||
(acc, highlight) => {
|
||||
acc[snakeCase(highlight.field)] = {
|
||||
pre_tags: [highlight.before],
|
||||
post_tags: [highlight.end],
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HighlightDSL>
|
||||
);
|
||||
return { fields };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BlockSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
doc_id: z.string(),
|
||||
block_id: z.string(),
|
||||
content: z.union([z.string(), z.string().array()]),
|
||||
flavour: z.string(),
|
||||
blob: z.union([z.string(), z.string().array()]).optional(),
|
||||
ref_doc_id: z.union([z.string(), z.string().array()]).optional(),
|
||||
ref: z.union([z.string(), z.string().array()]).optional(),
|
||||
parent_flavour: z.string().optional(),
|
||||
parent_block_id: z.string().optional(),
|
||||
additional: z.string().optional(),
|
||||
markdown_preview: z.string().optional(),
|
||||
created_by_user_id: z.string(),
|
||||
updated_by_user_id: z.string(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type Block = z.input<typeof BlockSchema>;
|
||||
|
||||
export function getBlockUniqueId(block: Block) {
|
||||
return `${block.workspace_id}/${block.doc_id}/${block.block_id}`;
|
||||
}
|
||||
|
||||
export const blockMapping = {
|
||||
settings: {
|
||||
analysis: {
|
||||
analyzer: {
|
||||
standard_with_cjk: {
|
||||
tokenizer: 'standard',
|
||||
filter: ['lowercase', 'cjk_bigram_and_unigrams'],
|
||||
},
|
||||
autocomplete: {
|
||||
tokenizer: 'autocomplete_tokenizer',
|
||||
filter: ['lowercase'],
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
autocomplete_tokenizer: {
|
||||
type: 'edge_ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 20,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
cjk_bigram_and_unigrams: {
|
||||
type: 'cjk_bigram',
|
||||
// output in unigram form, let `我是地球人` => `我`, `我是`, `是`, `是地`, `地`, `地球`, `球`, `球人`, `人`
|
||||
// @see https://www.elastic.co/docs/reference/text-analysis/analysis-cjk-bigram-tokenfilter#analysis-cjk-bigram-tokenfilter-configure-parms
|
||||
output_unigrams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
workspace_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
doc_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
block_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
analyzer: 'standard_with_cjk',
|
||||
search_analyzer: 'standard_with_cjk',
|
||||
},
|
||||
flavour: {
|
||||
type: 'keyword',
|
||||
},
|
||||
blob: {
|
||||
type: 'keyword',
|
||||
},
|
||||
ref_doc_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
ref: {
|
||||
type: 'text',
|
||||
index: false,
|
||||
},
|
||||
parent_flavour: {
|
||||
type: 'keyword',
|
||||
},
|
||||
parent_block_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
additional: {
|
||||
type: 'text',
|
||||
index: false,
|
||||
},
|
||||
markdown_preview: {
|
||||
type: 'text',
|
||||
index: false,
|
||||
},
|
||||
created_by_user_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
updated_by_user_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
created_at: {
|
||||
type: 'date',
|
||||
},
|
||||
updated_at: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const blockSQL = `
|
||||
CREATE TABLE IF NOT EXISTS block (
|
||||
workspace_id string attribute,
|
||||
doc_id string attribute,
|
||||
block_id string attribute,
|
||||
content text,
|
||||
flavour string attribute,
|
||||
-- use flavour_indexed to match with boost
|
||||
flavour_indexed string attribute indexed,
|
||||
blob string attribute indexed,
|
||||
-- ref_doc_id need match query
|
||||
ref_doc_id string attribute indexed,
|
||||
ref string stored,
|
||||
parent_flavour string attribute,
|
||||
-- use parent_flavour_indexed to match with boost
|
||||
parent_flavour_indexed string attribute indexed,
|
||||
parent_block_id string attribute,
|
||||
-- use parent_block_id_indexed to match with boost, exists query
|
||||
parent_block_id_indexed string attribute indexed,
|
||||
additional string stored,
|
||||
markdown_preview string stored,
|
||||
created_by_user_id string attribute,
|
||||
updated_by_user_id string attribute,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
)
|
||||
morphology = 'jieba_chinese, lemmatize_en_all, lemmatize_de_all, lemmatize_ru_all, libstemmer_ar, libstemmer_ca, stem_cz, libstemmer_da, libstemmer_nl, libstemmer_fi, libstemmer_fr, libstemmer_el, libstemmer_hi, libstemmer_hu, libstemmer_id, libstemmer_ga, libstemmer_it, libstemmer_lt, libstemmer_ne, libstemmer_no, libstemmer_pt, libstemmer_ro, libstemmer_es, libstemmer_sv, libstemmer_ta, libstemmer_tr'
|
||||
charset_table = 'non_cjk, cjk'
|
||||
index_field_lengths = '1'
|
||||
`;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DocSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
doc_id: z.string(),
|
||||
title: z.string(),
|
||||
summary: z.string(),
|
||||
journal: z.string().optional(),
|
||||
created_by_user_id: z.string(),
|
||||
updated_by_user_id: z.string(),
|
||||
created_at: z.date(),
|
||||
updated_at: z.date(),
|
||||
});
|
||||
|
||||
export type Doc = z.input<typeof DocSchema>;
|
||||
|
||||
export function getDocUniqueId(doc: Doc) {
|
||||
return `${doc.workspace_id}/${doc.doc_id}`;
|
||||
}
|
||||
|
||||
export const docMapping = {
|
||||
settings: {
|
||||
analysis: {
|
||||
analyzer: {
|
||||
standard_with_cjk: {
|
||||
tokenizer: 'standard',
|
||||
filter: ['lowercase', 'cjk_bigram_and_unigrams'],
|
||||
},
|
||||
autocomplete: {
|
||||
tokenizer: 'autocomplete_tokenizer',
|
||||
filter: ['lowercase'],
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
autocomplete_tokenizer: {
|
||||
type: 'edge_ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 20,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
filter: {
|
||||
cjk_bigram_and_unigrams: {
|
||||
type: 'cjk_bigram',
|
||||
output_unigrams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
workspace_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
doc_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
title: {
|
||||
type: 'text',
|
||||
analyzer: 'standard_with_cjk',
|
||||
search_analyzer: 'standard_with_cjk',
|
||||
fields: {
|
||||
autocomplete: {
|
||||
type: 'text',
|
||||
analyzer: 'autocomplete',
|
||||
search_analyzer: 'standard',
|
||||
},
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
type: 'text',
|
||||
index: false,
|
||||
},
|
||||
journal: {
|
||||
type: 'keyword',
|
||||
},
|
||||
created_by_user_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
updated_by_user_id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
created_at: {
|
||||
type: 'date',
|
||||
},
|
||||
updated_at: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const docSQL = `
|
||||
CREATE TABLE IF NOT EXISTS doc (
|
||||
workspace_id string attribute,
|
||||
doc_id string attribute,
|
||||
title text,
|
||||
summary string stored,
|
||||
journal string stored,
|
||||
created_by_user_id string attribute,
|
||||
updated_by_user_id string attribute,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
)
|
||||
morphology = 'jieba_chinese, lemmatize_en_all, lemmatize_de_all, lemmatize_ru_all, libstemmer_ar, libstemmer_ca, stem_cz, libstemmer_da, libstemmer_nl, libstemmer_fi, libstemmer_fr, libstemmer_el, libstemmer_hi, libstemmer_hu, libstemmer_id, libstemmer_ga, libstemmer_it, libstemmer_lt, libstemmer_ne, libstemmer_no, libstemmer_pt, libstemmer_ro, libstemmer_es, libstemmer_sv, libstemmer_ta, libstemmer_tr'
|
||||
charset_table = 'non_cjk, cjk'
|
||||
index_field_lengths = '1'
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getBlockUniqueId } from './block';
|
||||
import { getDocUniqueId } from './doc';
|
||||
|
||||
export enum SearchTable {
|
||||
block = 'block',
|
||||
doc = 'doc',
|
||||
}
|
||||
|
||||
export const SearchTableUniqueId = {
|
||||
[SearchTable.block]: getBlockUniqueId,
|
||||
[SearchTable.doc]: getDocUniqueId,
|
||||
};
|
||||
|
||||
export * from './block';
|
||||
export * from './doc';
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
createUnionType,
|
||||
Field,
|
||||
Float,
|
||||
InputType,
|
||||
Int,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { SearchTable } from './tables';
|
||||
|
||||
export enum SearchQueryType {
|
||||
match = 'match',
|
||||
boost = 'boost',
|
||||
boolean = 'boolean',
|
||||
exists = 'exists',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
export enum SearchQueryOccur {
|
||||
should = 'should',
|
||||
must = 'must',
|
||||
must_not = 'must_not',
|
||||
}
|
||||
|
||||
registerEnumType(SearchTable, {
|
||||
name: 'SearchTable',
|
||||
description: 'Search table',
|
||||
});
|
||||
|
||||
registerEnumType(SearchQueryType, {
|
||||
name: 'SearchQueryType',
|
||||
description: 'Search query type',
|
||||
});
|
||||
|
||||
registerEnumType(SearchQueryOccur, {
|
||||
name: 'SearchQueryOccur',
|
||||
description: 'Search query occur',
|
||||
});
|
||||
|
||||
@InputType()
|
||||
export class SearchQuery {
|
||||
@Field(() => SearchQueryType)
|
||||
type!: SearchQueryType;
|
||||
|
||||
@Field({ nullable: true })
|
||||
field?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
match?: string;
|
||||
|
||||
@Field(() => SearchQuery, { nullable: true })
|
||||
query?: SearchQuery;
|
||||
|
||||
@Field(() => [SearchQuery], { nullable: true })
|
||||
queries?: SearchQuery[];
|
||||
|
||||
@Field(() => SearchQueryOccur, { nullable: true })
|
||||
occur?: SearchQueryOccur;
|
||||
|
||||
@Field(() => Float, { nullable: true })
|
||||
boost?: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SearchHighlight {
|
||||
@Field()
|
||||
field!: string;
|
||||
|
||||
@Field()
|
||||
before!: string;
|
||||
|
||||
@Field()
|
||||
end!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SearchPagination {
|
||||
@Field({ nullable: true })
|
||||
limit?: number;
|
||||
|
||||
@Field({ nullable: true })
|
||||
skip?: number;
|
||||
|
||||
@Field({ nullable: true })
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SearchOptions {
|
||||
@Field(() => [String])
|
||||
fields!: string[];
|
||||
|
||||
@Field(() => [SearchHighlight], { nullable: true })
|
||||
highlights?: SearchHighlight[];
|
||||
|
||||
@Field(() => SearchPagination, { nullable: true })
|
||||
pagination?: SearchPagination;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SearchInput {
|
||||
@Field(() => SearchTable)
|
||||
table!: SearchTable;
|
||||
|
||||
@Field(() => SearchQuery)
|
||||
query!: SearchQuery;
|
||||
|
||||
@Field(() => SearchOptions)
|
||||
options!: SearchOptions;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AggregateHitsPagination {
|
||||
@Field({ nullable: true })
|
||||
limit?: number;
|
||||
|
||||
@Field({ nullable: true })
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AggregateHitsOptions {
|
||||
@Field(() => [String])
|
||||
fields!: string[];
|
||||
|
||||
@Field(() => [SearchHighlight], { nullable: true })
|
||||
highlights?: SearchHighlight[];
|
||||
|
||||
@Field(() => AggregateHitsPagination, { nullable: true })
|
||||
pagination?: AggregateHitsPagination;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AggregateOptions {
|
||||
@Field(() => AggregateHitsOptions)
|
||||
hits!: AggregateHitsOptions;
|
||||
|
||||
@Field(() => SearchPagination, { nullable: true })
|
||||
pagination?: SearchPagination;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AggregateInput {
|
||||
@Field(() => SearchTable)
|
||||
table!: SearchTable;
|
||||
|
||||
@Field(() => SearchQuery)
|
||||
query!: SearchQuery;
|
||||
|
||||
@Field(() => String)
|
||||
field!: string;
|
||||
|
||||
@Field(() => AggregateOptions)
|
||||
options!: AggregateOptions;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class BlockObjectType {
|
||||
@Field(() => [String], { nullable: true })
|
||||
workspaceId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
docId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
blockId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
content?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
flavour?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
blob?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
refDocId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
ref?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
parentFlavour?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
parentBlockId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
additional?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
markdownPreview?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
createdByUserId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
updatedByUserId?: string[];
|
||||
|
||||
@Field(() => [Date], { nullable: true })
|
||||
createdAt?: Date[];
|
||||
|
||||
@Field(() => [Date], { nullable: true })
|
||||
updatedAt?: Date[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class DocObjectType {
|
||||
@Field(() => [String], { nullable: true })
|
||||
workspaceId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
docId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
title?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
summary?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
journal?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
createdByUserId?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
updatedByUserId?: string[];
|
||||
|
||||
@Field(() => [Date], { nullable: true })
|
||||
createdAt?: Date[];
|
||||
|
||||
@Field(() => [Date], { nullable: true })
|
||||
updatedAt?: Date[];
|
||||
}
|
||||
|
||||
export const UnionSearchItemObjectType = createUnionType({
|
||||
name: 'UnionSearchItemObjectType',
|
||||
types: () => [BlockObjectType, DocObjectType] as const,
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class SearchNodeObjectType {
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
description: 'The search result fields, see UnionSearchItemObjectType',
|
||||
})
|
||||
fields!: object;
|
||||
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
description: 'The search result fields, see UnionSearchItemObjectType',
|
||||
nullable: true,
|
||||
})
|
||||
highlights?: object;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class SearchResultPagination {
|
||||
@Field(() => Int)
|
||||
count!: number;
|
||||
|
||||
@Field(() => Boolean)
|
||||
hasMore!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class SearchResultObjectType {
|
||||
@Field(() => [SearchNodeObjectType])
|
||||
nodes!: SearchNodeObjectType[];
|
||||
|
||||
@Field(() => SearchResultPagination)
|
||||
pagination!: SearchResultPagination;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AggregateBucketHitsObjectType {
|
||||
@Field(() => [SearchNodeObjectType])
|
||||
nodes!: SearchNodeObjectType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AggregateBucketObjectType {
|
||||
@Field(() => String)
|
||||
key!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
count!: number;
|
||||
|
||||
@Field(() => AggregateBucketHitsObjectType, {
|
||||
description: 'The hits object',
|
||||
})
|
||||
hits!: AggregateBucketHitsObjectType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AggregateResultObjectType {
|
||||
@Field(() => [AggregateBucketObjectType])
|
||||
buckets!: AggregateBucketObjectType[];
|
||||
|
||||
@Field(() => SearchResultPagination)
|
||||
pagination!: SearchResultPagination;
|
||||
}
|
||||
@@ -19,6 +19,46 @@ input AddContextFileInput {
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
type AggregateBucketHitsObjectType {
|
||||
nodes: [SearchNodeObjectType!]!
|
||||
}
|
||||
|
||||
type AggregateBucketObjectType {
|
||||
count: Int!
|
||||
|
||||
"""The hits object"""
|
||||
hits: AggregateBucketHitsObjectType!
|
||||
key: String!
|
||||
}
|
||||
|
||||
input AggregateHitsOptions {
|
||||
fields: [String!]!
|
||||
highlights: [SearchHighlight!]
|
||||
pagination: AggregateHitsPagination
|
||||
}
|
||||
|
||||
input AggregateHitsPagination {
|
||||
limit: Int
|
||||
skip: Int
|
||||
}
|
||||
|
||||
input AggregateInput {
|
||||
field: String!
|
||||
options: AggregateOptions!
|
||||
query: SearchQuery!
|
||||
table: SearchTable!
|
||||
}
|
||||
|
||||
input AggregateOptions {
|
||||
hits: AggregateHitsOptions!
|
||||
pagination: SearchPagination
|
||||
}
|
||||
|
||||
type AggregateResultObjectType {
|
||||
buckets: [AggregateBucketObjectType!]!
|
||||
pagination: SearchResultPagination!
|
||||
}
|
||||
|
||||
enum AiJobStatus {
|
||||
claimed
|
||||
failed
|
||||
@@ -475,7 +515,7 @@ type EditorType {
|
||||
name: String!
|
||||
}
|
||||
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
|
||||
enum ErrorNames {
|
||||
ACCESS_DENIED
|
||||
@@ -544,6 +584,7 @@ enum ErrorNames {
|
||||
INVALID_EMAIL
|
||||
INVALID_EMAIL_TOKEN
|
||||
INVALID_HISTORY_TIMESTAMP
|
||||
INVALID_INDEXER_INPUT
|
||||
INVALID_INVITATION
|
||||
INVALID_LICENSE_SESSION_ID
|
||||
INVALID_LICENSE_TO_ACTIVATE
|
||||
@@ -552,6 +593,7 @@ enum ErrorNames {
|
||||
INVALID_OAUTH_CALLBACK_STATE
|
||||
INVALID_PASSWORD_LENGTH
|
||||
INVALID_RUNTIME_CONFIG_TYPE
|
||||
INVALID_SEARCH_PROVIDER_REQUEST
|
||||
INVALID_SUBSCRIPTION_PARAMETERS
|
||||
LICENSE_EXPIRED
|
||||
LICENSE_NOT_FOUND
|
||||
@@ -578,6 +620,7 @@ enum ErrorNames {
|
||||
RUNTIME_CONFIG_NOT_FOUND
|
||||
SAME_EMAIL_PROVIDED
|
||||
SAME_SUBSCRIPTION_RECURRING
|
||||
SEARCH_PROVIDER_NOT_FOUND
|
||||
SIGN_UP_FORBIDDEN
|
||||
SPACE_ACCESS_DENIED
|
||||
SPACE_NOT_FOUND
|
||||
@@ -683,6 +726,10 @@ type InvalidHistoryTimestampDataType {
|
||||
timestamp: String!
|
||||
}
|
||||
|
||||
type InvalidIndexerInputDataType {
|
||||
reason: String!
|
||||
}
|
||||
|
||||
type InvalidLicenseToActivateDataType {
|
||||
reason: String!
|
||||
}
|
||||
@@ -707,6 +754,11 @@ type InvalidRuntimeConfigTypeDataType {
|
||||
want: String!
|
||||
}
|
||||
|
||||
type InvalidSearchProviderRequestDataType {
|
||||
reason: String!
|
||||
type: String!
|
||||
}
|
||||
|
||||
type InvitationAcceptedNotificationBodyType {
|
||||
"""
|
||||
The user who created the notification, maybe null when user is deleted or sent by system
|
||||
@@ -1403,6 +1455,81 @@ type SameSubscriptionRecurringDataType {
|
||||
recurring: String!
|
||||
}
|
||||
|
||||
input SearchHighlight {
|
||||
before: String!
|
||||
end: String!
|
||||
field: String!
|
||||
}
|
||||
|
||||
input SearchInput {
|
||||
options: SearchOptions!
|
||||
query: SearchQuery!
|
||||
table: SearchTable!
|
||||
}
|
||||
|
||||
type SearchNodeObjectType {
|
||||
"""The search result fields, see UnionSearchItemObjectType"""
|
||||
fields: JSONObject!
|
||||
|
||||
"""The search result fields, see UnionSearchItemObjectType"""
|
||||
highlights: JSONObject
|
||||
}
|
||||
|
||||
input SearchOptions {
|
||||
fields: [String!]!
|
||||
highlights: [SearchHighlight!]
|
||||
pagination: SearchPagination
|
||||
}
|
||||
|
||||
input SearchPagination {
|
||||
cursor: String
|
||||
limit: Int
|
||||
skip: Int
|
||||
}
|
||||
|
||||
input SearchQuery {
|
||||
boost: Float
|
||||
field: String
|
||||
match: String
|
||||
occur: SearchQueryOccur
|
||||
queries: [SearchQuery!]
|
||||
query: SearchQuery
|
||||
type: SearchQueryType!
|
||||
}
|
||||
|
||||
"""Search query occur"""
|
||||
enum SearchQueryOccur {
|
||||
must
|
||||
must_not
|
||||
should
|
||||
}
|
||||
|
||||
"""Search query type"""
|
||||
enum SearchQueryType {
|
||||
all
|
||||
boolean
|
||||
boost
|
||||
exists
|
||||
match
|
||||
}
|
||||
|
||||
type SearchResultObjectType {
|
||||
nodes: [SearchNodeObjectType!]!
|
||||
pagination: SearchResultPagination!
|
||||
}
|
||||
|
||||
type SearchResultPagination {
|
||||
count: Int!
|
||||
hasMore: Boolean!
|
||||
nextCursor: String
|
||||
}
|
||||
|
||||
"""Search table"""
|
||||
enum SearchTable {
|
||||
block
|
||||
doc
|
||||
}
|
||||
|
||||
type ServerConfigType {
|
||||
"""fetch latest available upgradable release of server"""
|
||||
availableUpgrade: ReleaseVersionType
|
||||
@@ -1441,6 +1568,7 @@ enum ServerDeploymentType {
|
||||
enum ServerFeature {
|
||||
Captcha
|
||||
Copilot
|
||||
Indexer
|
||||
OAuth
|
||||
Payment
|
||||
}
|
||||
@@ -1805,6 +1933,9 @@ type WorkspaceRolePermissions {
|
||||
}
|
||||
|
||||
type WorkspaceType {
|
||||
"""Search a specific table with aggregate"""
|
||||
aggregate(input: AggregateInput!): AggregateResultObjectType!
|
||||
|
||||
"""List blobs of workspace"""
|
||||
blobs: [ListedBlob!]!
|
||||
|
||||
@@ -1874,6 +2005,9 @@ type WorkspaceType {
|
||||
"""Role of current signed in user in workspace"""
|
||||
role: Permission!
|
||||
|
||||
"""Search a specific table"""
|
||||
search(input: SearchInput!): SearchResultObjectType!
|
||||
|
||||
"""The team subscription of the workspace, if exists."""
|
||||
subscription: SubscriptionType
|
||||
|
||||
|
||||
@@ -1328,6 +1328,52 @@ export const listHistoryQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const indexerAggregateQuery = {
|
||||
id: 'indexerAggregateQuery' as const,
|
||||
op: 'indexerAggregate',
|
||||
query: `query indexerAggregate($id: String!, $input: AggregateInput!) {
|
||||
workspace(id: $id) {
|
||||
aggregate(input: $input) {
|
||||
buckets {
|
||||
key
|
||||
count
|
||||
hits {
|
||||
nodes {
|
||||
fields
|
||||
highlights
|
||||
}
|
||||
}
|
||||
}
|
||||
pagination {
|
||||
count
|
||||
hasMore
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const indexerSearchQuery = {
|
||||
id: 'indexerSearchQuery' as const,
|
||||
op: 'indexerSearch',
|
||||
query: `query indexerSearch($id: String!, $input: SearchInput!) {
|
||||
workspace(id: $id) {
|
||||
search(input: $input) {
|
||||
nodes {
|
||||
fields
|
||||
highlights
|
||||
}
|
||||
pagination {
|
||||
count
|
||||
hasMore
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getInvoicesCountQuery = {
|
||||
id: 'getInvoicesCountQuery' as const,
|
||||
op: 'getInvoicesCount',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
query indexerAggregate($id: String!, $input: AggregateInput!) {
|
||||
workspace(id: $id) {
|
||||
aggregate(input: $input) {
|
||||
buckets {
|
||||
key
|
||||
count
|
||||
hits {
|
||||
nodes {
|
||||
fields
|
||||
highlights
|
||||
}
|
||||
}
|
||||
}
|
||||
pagination {
|
||||
count
|
||||
hasMore
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
query indexerSearch($id: String!, $input: SearchInput!) {
|
||||
workspace(id: $id) {
|
||||
search(input: $input) {
|
||||
nodes {
|
||||
fields
|
||||
highlights
|
||||
}
|
||||
pagination {
|
||||
count
|
||||
hasMore
|
||||
nextCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,48 @@ export interface AddContextFileInput {
|
||||
contextId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface AggregateBucketHitsObjectType {
|
||||
__typename?: 'AggregateBucketHitsObjectType';
|
||||
nodes: Array<SearchNodeObjectType>;
|
||||
}
|
||||
|
||||
export interface AggregateBucketObjectType {
|
||||
__typename?: 'AggregateBucketObjectType';
|
||||
count: Scalars['Int']['output'];
|
||||
/** The hits object */
|
||||
hits: AggregateBucketHitsObjectType;
|
||||
key: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface AggregateHitsOptions {
|
||||
fields: Array<Scalars['String']['input']>;
|
||||
highlights?: InputMaybe<Array<SearchHighlight>>;
|
||||
pagination?: InputMaybe<AggregateHitsPagination>;
|
||||
}
|
||||
|
||||
export interface AggregateHitsPagination {
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
skip?: InputMaybe<Scalars['Int']['input']>;
|
||||
}
|
||||
|
||||
export interface AggregateInput {
|
||||
field: Scalars['String']['input'];
|
||||
options: AggregateOptions;
|
||||
query: SearchQuery;
|
||||
table: SearchTable;
|
||||
}
|
||||
|
||||
export interface AggregateOptions {
|
||||
hits: AggregateHitsOptions;
|
||||
pagination?: InputMaybe<SearchPagination>;
|
||||
}
|
||||
|
||||
export interface AggregateResultObjectType {
|
||||
__typename?: 'AggregateResultObjectType';
|
||||
buckets: Array<AggregateBucketObjectType>;
|
||||
pagination: SearchResultPagination;
|
||||
}
|
||||
|
||||
export enum AiJobStatus {
|
||||
claimed = 'claimed',
|
||||
failed = 'failed',
|
||||
@@ -612,11 +654,13 @@ export type ErrorDataUnion =
|
||||
| HttpRequestErrorDataType
|
||||
| InvalidEmailDataType
|
||||
| InvalidHistoryTimestampDataType
|
||||
| InvalidIndexerInputDataType
|
||||
| InvalidLicenseToActivateDataType
|
||||
| InvalidLicenseUpdateParamsDataType
|
||||
| InvalidOauthCallbackCodeDataType
|
||||
| InvalidPasswordLengthDataType
|
||||
| InvalidRuntimeConfigTypeDataType
|
||||
| InvalidSearchProviderRequestDataType
|
||||
| MemberNotFoundInSpaceDataType
|
||||
| MentionUserDocAccessDeniedDataType
|
||||
| MissingOauthQueryParameterDataType
|
||||
@@ -707,6 +751,7 @@ export enum ErrorNames {
|
||||
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||
INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN',
|
||||
INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP',
|
||||
INVALID_INDEXER_INPUT = 'INVALID_INDEXER_INPUT',
|
||||
INVALID_INVITATION = 'INVALID_INVITATION',
|
||||
INVALID_LICENSE_SESSION_ID = 'INVALID_LICENSE_SESSION_ID',
|
||||
INVALID_LICENSE_TO_ACTIVATE = 'INVALID_LICENSE_TO_ACTIVATE',
|
||||
@@ -715,6 +760,7 @@ export enum ErrorNames {
|
||||
INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE',
|
||||
INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH',
|
||||
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
|
||||
INVALID_SEARCH_PROVIDER_REQUEST = 'INVALID_SEARCH_PROVIDER_REQUEST',
|
||||
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
|
||||
LICENSE_EXPIRED = 'LICENSE_EXPIRED',
|
||||
LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND',
|
||||
@@ -741,6 +787,7 @@ export enum ErrorNames {
|
||||
RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND',
|
||||
SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED',
|
||||
SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING',
|
||||
SEARCH_PROVIDER_NOT_FOUND = 'SEARCH_PROVIDER_NOT_FOUND',
|
||||
SIGN_UP_FORBIDDEN = 'SIGN_UP_FORBIDDEN',
|
||||
SPACE_ACCESS_DENIED = 'SPACE_ACCESS_DENIED',
|
||||
SPACE_NOT_FOUND = 'SPACE_NOT_FOUND',
|
||||
@@ -852,6 +899,11 @@ export interface InvalidHistoryTimestampDataType {
|
||||
timestamp: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface InvalidIndexerInputDataType {
|
||||
__typename?: 'InvalidIndexerInputDataType';
|
||||
reason: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface InvalidLicenseToActivateDataType {
|
||||
__typename?: 'InvalidLicenseToActivateDataType';
|
||||
reason: Scalars['String']['output'];
|
||||
@@ -881,6 +933,12 @@ export interface InvalidRuntimeConfigTypeDataType {
|
||||
want: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface InvalidSearchProviderRequestDataType {
|
||||
__typename?: 'InvalidSearchProviderRequestDataType';
|
||||
reason: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface InvitationAcceptedNotificationBodyType {
|
||||
__typename?: 'InvitationAcceptedNotificationBodyType';
|
||||
/** The user who created the notification, maybe null when user is deleted or sent by system */
|
||||
@@ -1950,6 +2008,83 @@ export interface SameSubscriptionRecurringDataType {
|
||||
recurring: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface SearchHighlight {
|
||||
before: Scalars['String']['input'];
|
||||
end: Scalars['String']['input'];
|
||||
field: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface SearchInput {
|
||||
options: SearchOptions;
|
||||
query: SearchQuery;
|
||||
table: SearchTable;
|
||||
}
|
||||
|
||||
export interface SearchNodeObjectType {
|
||||
__typename?: 'SearchNodeObjectType';
|
||||
/** The search result fields, see UnionSearchItemObjectType */
|
||||
fields: Scalars['JSONObject']['output'];
|
||||
/** The search result fields, see UnionSearchItemObjectType */
|
||||
highlights: Maybe<Scalars['JSONObject']['output']>;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
fields: Array<Scalars['String']['input']>;
|
||||
highlights?: InputMaybe<Array<SearchHighlight>>;
|
||||
pagination?: InputMaybe<SearchPagination>;
|
||||
}
|
||||
|
||||
export interface SearchPagination {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
skip?: InputMaybe<Scalars['Int']['input']>;
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
boost?: InputMaybe<Scalars['Float']['input']>;
|
||||
field?: InputMaybe<Scalars['String']['input']>;
|
||||
match?: InputMaybe<Scalars['String']['input']>;
|
||||
occur?: InputMaybe<SearchQueryOccur>;
|
||||
queries?: InputMaybe<Array<SearchQuery>>;
|
||||
query?: InputMaybe<SearchQuery>;
|
||||
type: SearchQueryType;
|
||||
}
|
||||
|
||||
/** Search query occur */
|
||||
export enum SearchQueryOccur {
|
||||
must = 'must',
|
||||
must_not = 'must_not',
|
||||
should = 'should',
|
||||
}
|
||||
|
||||
/** Search query type */
|
||||
export enum SearchQueryType {
|
||||
all = 'all',
|
||||
boolean = 'boolean',
|
||||
boost = 'boost',
|
||||
exists = 'exists',
|
||||
match = 'match',
|
||||
}
|
||||
|
||||
export interface SearchResultObjectType {
|
||||
__typename?: 'SearchResultObjectType';
|
||||
nodes: Array<SearchNodeObjectType>;
|
||||
pagination: SearchResultPagination;
|
||||
}
|
||||
|
||||
export interface SearchResultPagination {
|
||||
__typename?: 'SearchResultPagination';
|
||||
count: Scalars['Int']['output'];
|
||||
hasMore: Scalars['Boolean']['output'];
|
||||
nextCursor: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
/** Search table */
|
||||
export enum SearchTable {
|
||||
block = 'block',
|
||||
doc = 'doc',
|
||||
}
|
||||
|
||||
export interface ServerConfigType {
|
||||
__typename?: 'ServerConfigType';
|
||||
/** fetch latest available upgradable release of server */
|
||||
@@ -1981,6 +2116,7 @@ export enum ServerDeploymentType {
|
||||
export enum ServerFeature {
|
||||
Captcha = 'Captcha',
|
||||
Copilot = 'Copilot',
|
||||
Indexer = 'Indexer',
|
||||
OAuth = 'OAuth',
|
||||
Payment = 'Payment',
|
||||
}
|
||||
@@ -2382,6 +2518,8 @@ export interface WorkspaceRolePermissions {
|
||||
|
||||
export interface WorkspaceType {
|
||||
__typename?: 'WorkspaceType';
|
||||
/** Search a specific table with aggregate */
|
||||
aggregate: AggregateResultObjectType;
|
||||
/** List blobs of workspace */
|
||||
blobs: Array<ListedBlob>;
|
||||
/** Blobs size of workspace */
|
||||
@@ -2437,12 +2575,18 @@ export interface WorkspaceType {
|
||||
quota: WorkspaceQuotaType;
|
||||
/** Role of current signed in user in workspace */
|
||||
role: Permission;
|
||||
/** Search a specific table */
|
||||
search: SearchResultObjectType;
|
||||
/** The team subscription of the workspace, if exists. */
|
||||
subscription: Maybe<SubscriptionType>;
|
||||
/** if workspace is team workspace */
|
||||
team: Scalars['Boolean']['output'];
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeAggregateArgs {
|
||||
input: AggregateInput;
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeDocArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -2476,6 +2620,10 @@ export interface WorkspaceTypePublicPageArgs {
|
||||
pageId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeSearchArgs {
|
||||
input: SearchInput;
|
||||
}
|
||||
|
||||
export interface WorkspaceUserType {
|
||||
__typename?: 'WorkspaceUserType';
|
||||
avatarUrl: Maybe<Scalars['String']['output']>;
|
||||
@@ -3997,6 +4145,66 @@ export type ListHistoryQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type IndexerAggregateQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
input: AggregateInput;
|
||||
}>;
|
||||
|
||||
export type IndexerAggregateQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
aggregate: {
|
||||
__typename?: 'AggregateResultObjectType';
|
||||
buckets: Array<{
|
||||
__typename?: 'AggregateBucketObjectType';
|
||||
key: string;
|
||||
count: number;
|
||||
hits: {
|
||||
__typename?: 'AggregateBucketHitsObjectType';
|
||||
nodes: Array<{
|
||||
__typename?: 'SearchNodeObjectType';
|
||||
fields: any;
|
||||
highlights: any | null;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
pagination: {
|
||||
__typename?: 'SearchResultPagination';
|
||||
count: number;
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type IndexerSearchQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
input: SearchInput;
|
||||
}>;
|
||||
|
||||
export type IndexerSearchQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
search: {
|
||||
__typename?: 'SearchResultObjectType';
|
||||
nodes: Array<{
|
||||
__typename?: 'SearchNodeObjectType';
|
||||
fields: any;
|
||||
highlights: any | null;
|
||||
}>;
|
||||
pagination: {
|
||||
__typename?: 'SearchResultPagination';
|
||||
count: number;
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type GetInvoicesCountQuery = {
|
||||
@@ -4924,6 +5132,16 @@ export type Queries =
|
||||
variables: ListHistoryQueryVariables;
|
||||
response: ListHistoryQuery;
|
||||
}
|
||||
| {
|
||||
name: 'indexerAggregateQuery';
|
||||
variables: IndexerAggregateQueryVariables;
|
||||
response: IndexerAggregateQuery;
|
||||
}
|
||||
| {
|
||||
name: 'indexerSearchQuery';
|
||||
variables: IndexerSearchQueryVariables;
|
||||
response: IndexerSearchQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getInvoicesCountQuery';
|
||||
variables: GetInvoicesCountQueryVariables;
|
||||
|
||||
@@ -260,6 +260,33 @@
|
||||
"desc": "Customer.io token"
|
||||
}
|
||||
},
|
||||
"indexer": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable indexer plugin"
|
||||
},
|
||||
"provider.type": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service provider name",
|
||||
"env": "AFFINE_INDEXER_SEARCH_PROVIDER"
|
||||
},
|
||||
"provider.endpoint": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service endpoint",
|
||||
"env": "AFFINE_INDEXER_SEARCH_ENDPOINT"
|
||||
},
|
||||
"provider.username": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service auth username, if not set, basic auth will be disabled. Optional for elasticsearch",
|
||||
"link": "https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html",
|
||||
"env": "AFFINE_INDEXER_SEARCH_USERNAME"
|
||||
},
|
||||
"provider.password": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service auth password, if not set, basic auth will be disabled. Optional for elasticsearch",
|
||||
"env": "AFFINE_INDEXER_SEARCH_PASSWORD"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"providers.google": {
|
||||
"type": "Object",
|
||||
|
||||
@@ -8552,6 +8552,22 @@ export function useAFFiNEI18N(): {
|
||||
* `Invalid app config.`
|
||||
*/
|
||||
["error.INVALID_APP_CONFIG"](): string;
|
||||
/**
|
||||
* `Search provider not found.`
|
||||
*/
|
||||
["error.SEARCH_PROVIDER_NOT_FOUND"](): string;
|
||||
/**
|
||||
* `Invalid request argument to search provider: {{reason}}`
|
||||
*/
|
||||
["error.INVALID_SEARCH_PROVIDER_REQUEST"](options: {
|
||||
readonly reason: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Invalid indexer input: {{reason}}`
|
||||
*/
|
||||
["error.INVALID_INDEXER_INPUT"](options: {
|
||||
readonly reason: string;
|
||||
}): string;
|
||||
} { const { t } = useTranslation(); return useMemo(() => createProxy((key) => t.bind(null, key)), [t]); }
|
||||
function createComponent(i18nKey: string) {
|
||||
return (props) => createElement(Trans, { i18nKey, shouldUnescape: true, ...props });
|
||||
|
||||
@@ -2110,5 +2110,8 @@
|
||||
"error.NOTIFICATION_NOT_FOUND": "Notification not found.",
|
||||
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",
|
||||
"error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.",
|
||||
"error.INVALID_APP_CONFIG": "Invalid app config."
|
||||
"error.INVALID_APP_CONFIG": "Invalid app config.",
|
||||
"error.SEARCH_PROVIDER_NOT_FOUND": "Search provider not found.",
|
||||
"error.INVALID_SEARCH_PROVIDER_REQUEST": "Invalid request argument to search provider: {{reason}}",
|
||||
"error.INVALID_INDEXER_INPUT": "Invalid indexer input: {{reason}}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user