feat(core): use cloud indexer for search (#12899)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added enhanced error handling and user-friendly error messages in
quick search and document search menus.
  - Introduced loading state indicators for search operations.
  - Quick Search now provides explicit error feedback in the UI.

- **Improvements**
- Search and aggregation operations can now prefer remote or local
indexers based on user or system preference.
- Streamlined indexer logic for more consistent and reliable search
experiences.
- Refined error handling in messaging and synchronization layers for
improved stability.
- Enhanced error object handling in messaging for clearer error
propagation.
- Updated cloud workspace storage to always use IndexedDB locally and
CloudIndexer remotely.
- Shifted indexer operations to use synchronized indexer layer for
better consistency.
  - Simplified indexer client by consolidating storage and sync layers.
- Improved error propagation in messaging handlers by wrapping error
objects.
- Updated document search to prioritize remote indexer results by
default.

- **Bug Fixes**
- Improved robustness of search features by handling errors gracefully
and preventing potential runtime issues.

- **Style**
  - Added new styles for displaying error messages in search interfaces.

- **Chores**
- Removed the obsolete "Enable Cloud Indexer" feature flag; cloud
indexer behavior is now always enabled where applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN 2025-06-25 10:55:27 +08:00 committed by GitHub
parent 6813d84deb
commit aa4874a55c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 366 additions and 178 deletions

View File

@ -43,7 +43,10 @@ describe('op consumer', () => {
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(` expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[ [
{ {
"error": [Error: Handler for operation [add] is not registered.], "error": {
"message": "Handler for operation [add] is not registered.",
"name": "Error",
},
"id": "add:1", "id": "add:1",
"type": "return", "type": "return",
}, },

View File

@ -61,7 +61,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
} }
if ('error' in msg) { if ('error' in msg) {
pending.reject(msg.error); pending.reject(Object.assign(new Error(), msg.error));
} else { } else {
pending.resolve(msg.data); pending.resolve(msg.data);
} }
@ -86,7 +86,7 @@ export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
return; return;
} }
ob.error(msg.error); ob.error(Object.assign(new Error(), msg.error));
}; };
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] = private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =

View File

@ -1,4 +1,5 @@
import EventEmitter2 from 'eventemitter2'; import EventEmitter2 from 'eventemitter2';
import { pick } from 'lodash-es';
import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs'; import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs';
import { MANUALLY_STOP } from '../utils'; import { MANUALLY_STOP } from '../utils';
@ -70,7 +71,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({ this.port.postMessage({
type: 'return', type: 'return',
id: msg.id, id: msg.id,
error: error as Error, error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies ReturnMessage); } satisfies ReturnMessage);
}, },
complete: () => { complete: () => {
@ -100,7 +109,15 @@ export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
this.port.postMessage({ this.port.postMessage({
type: 'error', type: 'error',
id: msg.id, id: msg.id,
error: error as Error, error: pick(error, [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
]),
} satisfies SubscriptionErrorMessage); } satisfies SubscriptionErrorMessage);
}, },
complete: () => { complete: () => {

View File

@ -1,20 +1,13 @@
import { switchMap } from 'rxjs';
import type { import type {
AggregateOptions, AggregateOptions,
IndexerSchema, IndexerSchema,
IndexerStorage,
Query, Query,
SearchOptions, SearchOptions,
} from '../storage'; } from '../storage';
import type { IndexerSync } from '../sync/indexer'; import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer';
import { fromPromise } from '../utils/from-promise';
export class IndexerFrontend { export class IndexerFrontend {
constructor( constructor(public readonly sync: IndexerSync) {}
public readonly storage: IndexerStorage,
public readonly sync: IndexerSync
) {}
get state$() { get state$() {
return this.sync.state$; return this.sync.state$;
@ -27,47 +20,47 @@ export class IndexerFrontend {
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>( async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T, table: T,
query: Query<T>, query: Query<T>,
options?: O options?: O & { prefer?: IndexerPreferOptions }
) { ) {
await this.waitForConnected(); return this.sync.search(table, query, options);
return this.storage.search(table, query, options);
} }
async aggregate< async aggregate<
T extends keyof IndexerSchema, T extends keyof IndexerSchema,
const O extends AggregateOptions<T>, const O extends AggregateOptions<T>,
>(table: T, query: Query<T>, field: keyof IndexerSchema[T], options?: O) { >(
await this.waitForConnected(); table: T,
return this.storage.aggregate(table, query, field, options); query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
) {
return this.sync.aggregate(table, query, field, options);
} }
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>( search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T, table: T,
query: Query<T>, query: Query<T>,
options?: O options?: O & { prefer?: IndexerPreferOptions }
) { ) {
return fromPromise(signal => this.waitForConnected(signal)).pipe( return this.sync.search$(table, query, options);
switchMap(() => this.storage.search$(table, query, options))
);
} }
aggregate$< aggregate$<
T extends keyof IndexerSchema, T extends keyof IndexerSchema,
const O extends AggregateOptions<T>, const O extends AggregateOptions<T>,
>(table: T, query: Query<T>, field: keyof IndexerSchema[T], options?: O) { >(
return fromPromise(signal => this.waitForConnected(signal)).pipe( table: T,
switchMap(() => this.storage.aggregate$(table, query, field, options)) query: Query<T>,
); field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
) {
return this.sync.aggregate$(table, query, field, options);
} }
addPriority(docId: string, priority: number) { addPriority(docId: string, priority: number) {
return this.sync.addPriority(docId, priority); return this.sync.addPriority(docId, priority);
} }
private waitForConnected(signal?: AbortSignal) {
return this.storage.connection.waitForConnected(signal);
}
waitForCompleted(signal?: AbortSignal) { waitForCompleted(signal?: AbortSignal) {
return this.sync.waitForCompleted(signal); return this.sync.waitForCompleted(signal);
} }

View File

@ -9,7 +9,11 @@ import type { PeerStorageOptions } from './types';
export type { BlobSyncState } from './blob'; export type { BlobSyncState } from './blob';
export type { DocSyncDocState, DocSyncState } from './doc'; export type { DocSyncDocState, DocSyncState } from './doc';
export type { IndexerDocSyncState, IndexerSyncState } from './indexer'; export type {
IndexerDocSyncState,
IndexerPreferOptions,
IndexerSyncState,
} from './indexer';
export interface SyncState { export interface SyncState {
doc?: DocSyncState; doc?: DocSyncState;
@ -65,7 +69,19 @@ export class Sync {
]) ])
), ),
}); });
this.indexer = new IndexerSyncImpl(doc, indexer, indexerSync); this.indexer = new IndexerSyncImpl(
doc,
{
local: indexer,
remotes: Object.fromEntries(
Object.entries(storages.remotes).map(([peerId, remote]) => [
peerId,
remote.get('indexer'),
])
),
},
indexerSync
);
this.state$ = this.doc.state$.pipe(map(doc => ({ doc }))); this.state$ = this.doc.state$.pipe(map(doc => ({ doc })));
} }

View File

@ -1,4 +1,5 @@
import { readAllDocsFromRootDoc } from '@affine/reader'; import { readAllDocsFromRootDoc } from '@affine/reader';
import { omit } from 'lodash-es';
import { import {
filter, filter,
first, first,
@ -7,21 +8,33 @@ import {
ReplaySubject, ReplaySubject,
share, share,
Subject, Subject,
switchMap,
throttleTime, throttleTime,
} from 'rxjs'; } from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs'; import { applyUpdate, Doc as YDoc } from 'yjs';
import { import {
type AggregateOptions,
type AggregateResult,
type DocStorage, type DocStorage,
IndexerDocument, IndexerDocument,
type IndexerSchema,
type IndexerStorage, type IndexerStorage,
type Query,
type SearchOptions,
type SearchResult,
} from '../../storage'; } from '../../storage';
import { DummyIndexerStorage } from '../../storage/dummy/indexer';
import type { IndexerSyncStorage } from '../../storage/indexer-sync'; import type { IndexerSyncStorage } from '../../storage/indexer-sync';
import { AsyncPriorityQueue } from '../../utils/async-priority-queue'; import { AsyncPriorityQueue } from '../../utils/async-priority-queue';
import { fromPromise } from '../../utils/from-promise';
import { takeUntilAbort } from '../../utils/take-until-abort'; import { takeUntilAbort } from '../../utils/take-until-abort';
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted'; import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
import type { PeerStorageOptions } from '../types';
import { crawlingDocData } from './crawler'; import { crawlingDocData } from './crawler';
export type IndexerPreferOptions = 'local' | 'remote';
export interface IndexerSyncState { export interface IndexerSyncState {
/** /**
* Number of documents currently in the indexing queue * Number of documents currently in the indexing queue
@ -59,6 +72,35 @@ export interface IndexerSync {
addPriority(docId: string, priority: number): () => void; addPriority(docId: string, priority: number): () => void;
waitForCompleted(signal?: AbortSignal): Promise<void>; waitForCompleted(signal?: AbortSignal): Promise<void>;
waitForDocCompleted(docId: string, signal?: AbortSignal): Promise<void>; waitForDocCompleted(docId: string, signal?: AbortSignal): Promise<void>;
search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>>;
aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>>;
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>>;
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>>;
} }
export class IndexerSyncImpl implements IndexerSync { export class IndexerSyncImpl implements IndexerSync {
@ -70,6 +112,9 @@ export class IndexerSyncImpl implements IndexerSync {
private readonly rootDocId = this.doc.spaceId; private readonly rootDocId = this.doc.spaceId;
private readonly status = new IndexerSyncStatus(this.rootDocId); private readonly status = new IndexerSyncStatus(this.rootDocId);
private readonly indexer: IndexerStorage;
private readonly remote?: IndexerStorage;
state$ = this.status.state$.pipe( state$ = this.status.state$.pipe(
// throttle the state to 1 second to avoid spamming the UI // throttle the state to 1 second to avoid spamming the UI
throttleTime(1000, undefined, { throttleTime(1000, undefined, {
@ -106,9 +151,13 @@ export class IndexerSyncImpl implements IndexerSync {
constructor( constructor(
readonly doc: DocStorage, readonly doc: DocStorage,
readonly indexer: IndexerStorage, readonly peers: PeerStorageOptions<IndexerStorage>,
readonly indexerSync: IndexerSyncStorage readonly indexerSync: IndexerSyncStorage
) {} ) {
// sync feature only works on local indexer
this.indexer = this.peers.local;
this.remote = Object.values(this.peers.remotes).find(remote => !!remote);
}
start() { start() {
if (this.abort) { if (this.abort) {
@ -439,6 +488,116 @@ export class IndexerSyncImpl implements IndexerSync {
}) })
); );
} }
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.search(table, query, omit(options, 'prefer'));
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.search(table, query, omit(options, 'prefer'));
}
}
async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
}
}
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() => remote.search$(table, query, omit(options, 'prefer')))
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.search$(table, query, omit(options, 'prefer'))
)
);
}
}
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
remote.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
}
}
} }
class IndexerSyncStatus { class IndexerSyncStatus {

View File

@ -18,9 +18,7 @@ import {
type DocRecord, type DocRecord,
type DocStorage, type DocStorage,
type DocUpdate, type DocUpdate,
type IndexerDocument,
type IndexerSchema, type IndexerSchema,
type IndexerStorage,
type ListedBlobRecord, type ListedBlobRecord,
type Query, type Query,
type SearchOptions, type SearchOptions,
@ -29,7 +27,7 @@ import {
import type { AwarenessSync } from '../sync/awareness'; import type { AwarenessSync } from '../sync/awareness';
import type { BlobSync } from '../sync/blob'; import type { BlobSync } from '../sync/blob';
import type { DocSync } from '../sync/doc'; import type { DocSync } from '../sync/doc';
import type { IndexerSync } from '../sync/indexer'; import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer';
import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops'; import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops';
export type { StoreInitOptions as WorkerInitOptions } from './ops'; export type { StoreInitOptions as WorkerInitOptions } from './ops';
@ -100,12 +98,8 @@ export class StoreClient {
this.docFrontend = new DocFrontend(this.docStorage, this.docSync); this.docFrontend = new DocFrontend(this.docStorage, this.docSync);
this.blobFrontend = new BlobFrontend(this.blobStorage, this.blobSync); this.blobFrontend = new BlobFrontend(this.blobStorage, this.blobSync);
this.awarenessFrontend = new AwarenessFrontend(this.awarenessSync); this.awarenessFrontend = new AwarenessFrontend(this.awarenessSync);
this.indexerStorage = new WorkerIndexerStorage(this.client);
this.indexerSync = new WorkerIndexerSync(this.client); this.indexerSync = new WorkerIndexerSync(this.client);
this.indexerFrontend = new IndexerFrontend( this.indexerFrontend = new IndexerFrontend(this.indexerSync);
this.indexerStorage,
this.indexerSync
);
} }
private readonly docStorage: WorkerDocStorage; private readonly docStorage: WorkerDocStorage;
@ -113,7 +107,6 @@ export class StoreClient {
private readonly docSync: WorkerDocSync; private readonly docSync: WorkerDocSync;
private readonly blobSync: WorkerBlobSync; private readonly blobSync: WorkerBlobSync;
private readonly awarenessSync: WorkerAwarenessSync; private readonly awarenessSync: WorkerAwarenessSync;
private readonly indexerStorage: WorkerIndexerStorage;
private readonly indexerSync: WorkerIndexerSync; private readonly indexerSync: WorkerIndexerSync;
readonly docFrontend: DocFrontend; readonly docFrontend: DocFrontend;
@ -348,26 +341,23 @@ class WorkerAwarenessSync implements AwarenessSync {
} }
} }
class WorkerIndexerStorage implements IndexerStorage { class WorkerIndexerSync implements IndexerSync {
constructor(private readonly client: OpClient<WorkerOps>) {} constructor(private readonly client: OpClient<WorkerOps>) {}
readonly storageType = 'indexer';
readonly isReadonly = true;
connection = new WorkerIndexerConnection(this.client);
search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>( search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T, table: T,
query: Query<T>, query: Query<T>,
options?: O options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>> { ): Promise<SearchResult<T, O>> {
return this.client.call('indexerStorage.search', { table, query, options }); return this.client.call('indexerSync.search', { table, query, options });
} }
aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>( aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>(
table: T, table: T,
query: Query<T>, query: Query<T>,
field: keyof IndexerSchema[T], field: keyof IndexerSchema[T],
options?: O options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>> { ): Promise<AggregateResult<T, O>> {
return this.client.call('indexerStorage.aggregate', { return this.client.call('indexerSync.aggregate', {
table, table,
query, query,
field: field as string, field: field as string,
@ -377,9 +367,9 @@ class WorkerIndexerStorage implements IndexerStorage {
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>( search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T, table: T,
query: Query<T>, query: Query<T>,
options?: O options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>> { ): Observable<SearchResult<T, O>> {
return this.client.ob$('indexerStorage.subscribeSearch', { return this.client.ob$('indexerSync.subscribeSearch', {
table, table,
query, query,
options, options,
@ -392,59 +382,16 @@ class WorkerIndexerStorage implements IndexerStorage {
table: T, table: T,
query: Query<T>, query: Query<T>,
field: keyof IndexerSchema[T], field: keyof IndexerSchema[T],
options?: O options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>> { ): Observable<AggregateResult<T, O>> {
return this.client.ob$('indexerStorage.subscribeAggregate', { return this.client.ob$('indexerSync.subscribeAggregate', {
table, table,
query, query,
field: field as string, field: field as string,
options, options,
}); });
} }
deleteByQuery<T extends keyof IndexerSchema>(
_table: T,
_query: Query<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
insert<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
delete<T extends keyof IndexerSchema>(_table: T, _id: string): Promise<void> {
throw new Error('Method not implemented.');
}
update<T extends keyof IndexerSchema>(
_table: T,
_document: IndexerDocument<T>
): Promise<void> {
throw new Error('Method not implemented.');
}
refresh<T extends keyof IndexerSchema>(_table: T): Promise<void> {
throw new Error('Method not implemented.');
}
}
class WorkerIndexerConnection extends DummyConnection {
constructor(private readonly client: OpClient<WorkerOps>) {
super();
}
promise: Promise<void> | undefined;
override waitForConnected(): Promise<void> {
if (this.promise) {
return this.promise;
}
this.promise = this.client.call('indexerStorage.waitForConnected');
return this.promise;
}
}
class WorkerIndexerSync implements IndexerSync {
constructor(private readonly client: OpClient<WorkerOps>) {}
waitForCompleted(signal?: AbortSignal): Promise<void> { waitForCompleted(signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const abortListener = () => { const abortListener = () => {

View File

@ -265,16 +265,6 @@ class StoreConsumer {
}), }),
'awarenessSync.collect': ({ collectId, awareness }) => 'awarenessSync.collect': ({ collectId, awareness }) =>
collectJobs.get(collectId)?.(awareness), collectJobs.get(collectId)?.(awareness),
'indexerStorage.aggregate': ({ table, query, field, options }) =>
this.indexerStorage.aggregate(table, query, field, options),
'indexerStorage.search': ({ table, query, options }) =>
this.indexerStorage.search(table, query, options),
'indexerStorage.subscribeSearch': ({ table, query, options }) =>
this.indexerStorage.search$(table, query, options),
'indexerStorage.subscribeAggregate': ({ table, query, field, options }) =>
this.indexerStorage.aggregate$(table, query, field, options),
'indexerStorage.waitForConnected': (_, ctx) =>
this.indexerStorage.connection.waitForConnected(ctx.signal),
'indexerSync.state': () => this.indexerSync.state$, 'indexerSync.state': () => this.indexerSync.state$,
'indexerSync.docState': (docId: string) => 'indexerSync.docState': (docId: string) =>
this.indexerSync.docState$(docId), this.indexerSync.docState$(docId),
@ -287,6 +277,14 @@ class StoreConsumer {
this.indexerSync.waitForCompleted(ctx.signal), this.indexerSync.waitForCompleted(ctx.signal),
'indexerSync.waitForDocCompleted': (docId: string, ctx) => 'indexerSync.waitForDocCompleted': (docId: string, ctx) =>
this.indexerSync.waitForDocCompleted(docId, ctx.signal), this.indexerSync.waitForDocCompleted(docId, ctx.signal),
'indexerSync.aggregate': ({ table, query, field, options }) =>
this.indexerSync.aggregate(table, query, field, options),
'indexerSync.search': ({ table, query, options }) =>
this.indexerSync.search(table, query, options),
'indexerSync.subscribeSearch': ({ table, query, options }) =>
this.indexerSync.search$(table, query, options),
'indexerSync.subscribeAggregate': ({ table, query, field, options }) =>
this.indexerSync.aggregate$(table, query, field, options),
}); });
} }
} }

View File

@ -1,6 +1,5 @@
import type { AvailableStorageImplementations } from '../impls'; import type { AvailableStorageImplementations } from '../impls';
import type { import type {
AggregateOptions,
AggregateResult, AggregateResult,
BlobRecord, BlobRecord,
DocClock, DocClock,
@ -10,7 +9,6 @@ import type {
DocUpdate, DocUpdate,
ListedBlobRecord, ListedBlobRecord,
Query, Query,
SearchOptions,
SearchResult, SearchResult,
StorageType, StorageType,
} from '../storage'; } from '../storage';
@ -69,36 +67,6 @@ interface GroupedWorkerOps {
waitForConnected: [void, void]; waitForConnected: [void, void];
}; };
indexerStorage: {
search: [
{ table: string; query: Query<any>; options?: SearchOptions<any> },
SearchResult<any, any>,
];
aggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: AggregateOptions<any>;
},
AggregateResult<any, any>,
];
subscribeSearch: [
{ table: string; query: Query<any>; options?: SearchOptions<any> },
SearchResult<any, any>,
];
subscribeAggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: AggregateOptions<any>;
},
AggregateResult<any, any>,
];
waitForConnected: [void, void];
};
docSync: { docSync: {
state: [void, DocSyncState]; state: [void, DocSyncState];
docState: [string, DocSyncDocState]; docState: [string, DocSyncDocState];
@ -137,6 +105,40 @@ interface GroupedWorkerOps {
addPriority: [{ docId: string; priority: number }, boolean]; addPriority: [{ docId: string; priority: number }, boolean];
waitForCompleted: [void, void]; waitForCompleted: [void, void];
waitForDocCompleted: [string, void]; waitForDocCompleted: [string, void];
search: [
{
table: string;
query: Query<any>;
options?: any;
},
SearchResult<any, any>,
];
aggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: any;
},
AggregateResult<any, any>,
];
subscribeSearch: [
{
table: string;
query: Query<any>;
options?: any;
},
SearchResult<any, any>,
];
subscribeAggregate: [
{
table: string;
query: Query<any>;
field: string;
options?: any;
},
AggregateResult<any, any>,
];
}; };
} }

View File

@ -112,6 +112,7 @@ export class DocsSearchService extends Service {
}, },
], ],
}, },
prefer: 'remote',
} }
) )
.pipe( .pipe(

View File

@ -3,7 +3,6 @@ import type { FlagInfo } from './types';
// const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable'; // const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable';
const isDesktopEnvironment = BUILD_CONFIG.isElectron; const isDesktopEnvironment = BUILD_CONFIG.isElectron;
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary'; const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
const isBetaBuild = BUILD_CONFIG.appBuildType === 'beta';
const isMobile = BUILD_CONFIG.isMobileEdition; const isMobile = BUILD_CONFIG.isMobileEdition;
export const AFFINE_FLAGS = { export const AFFINE_FLAGS = {
@ -266,13 +265,6 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild, configurable: isCanaryBuild,
defaultState: false, defaultState: false,
}, },
enable_cloud_indexer: {
category: 'affine',
displayName: 'Enable Cloud Indexer',
description: 'Use cloud indexer to search docs',
configurable: isBetaBuild || isCanaryBuild,
defaultState: false,
},
enable_adapter_panel: { enable_adapter_panel: {
category: 'affine', category: 'affine',
displayName: displayName:

View File

@ -25,6 +25,11 @@ export class QuickSearch extends Entity {
.flat() .flat()
.map(items => items.flat()); .map(items => items.flat());
readonly error$ = this.state$
.map(s => s?.sessions.map(session => session.error$) ?? [])
.flat()
.map(items => items.find(v => !!v) ?? null);
readonly show$ = this.state$.map(s => !!s); readonly show$ = this.state$.map(s => !!s);
readonly options$ = this.state$.map(s => s?.options); readonly options$ = this.state$.map(s => s?.options);

View File

@ -6,11 +6,12 @@ import {
onStart, onStart,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { truncate } from 'lodash-es'; import { truncate } from 'lodash-es';
import { map, of, switchMap, tap, throttleTime } from 'rxjs'; import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs';
import type { DocRecord, DocsService } from '../../doc'; import type { DocRecord, DocsService } from '../../doc';
import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search'; import type { DocsSearchService } from '../../docs-search';
import type { WorkspaceService } from '../../workspace';
import type { QuickSearchSession } from '../providers/quick-search-provider'; import type { QuickSearchSession } from '../providers/quick-search-provider';
import type { QuickSearchItem } from '../types/item'; import type { QuickSearchItem } from '../types/item';
@ -26,6 +27,7 @@ export class DocsQuickSearchSession
implements QuickSearchSession<'docs', DocsPayload> implements QuickSearchSession<'docs', DocsPayload>
{ {
constructor( constructor(
private readonly workspaceService: WorkspaceService,
private readonly docsSearchService: DocsSearchService, private readonly docsSearchService: DocsSearchService,
private readonly docsService: DocsService, private readonly docsService: DocsService,
private readonly docDisplayMetaService: DocDisplayMetaService private readonly docDisplayMetaService: DocDisplayMetaService
@ -41,10 +43,17 @@ export class DocsQuickSearchSession
private readonly isQueryLoading$ = new LiveData(false); private readonly isQueryLoading$ = new LiveData(false);
isCloudWorkspace = this.workspaceService.workspace.flavour !== 'local';
isLoading$ = LiveData.computed(get => { isLoading$ = LiveData.computed(get => {
return get(this.isIndexerLoading$) || get(this.isQueryLoading$); return (
(this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) ||
get(this.isQueryLoading$)
);
}); });
error$ = new LiveData<any>(null);
query$ = new LiveData(''); query$ = new LiveData('');
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]); items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
@ -102,9 +111,16 @@ export class DocsQuickSearchSession
this.isQueryLoading$.next(false); this.isQueryLoading$.next(false);
}), }),
onStart(() => { onStart(() => {
this.error$.next(null);
this.items$.next([]); this.items$.next([]);
this.isQueryLoading$.next(true); this.isQueryLoading$.next(true);
}), }),
catchError(err => {
this.error$.next(err instanceof Error ? err.message : err);
this.items$.next([]);
this.isQueryLoading$.next(false);
return EMPTY;
}),
onComplete(() => {}) onComplete(() => {})
); );
}) })

View File

@ -55,6 +55,7 @@ export function configureQuickSearchModule(framework: Framework) {
.entity(QuickSearch) .entity(QuickSearch)
.entity(CommandsQuickSearchSession, [GlobalContextService]) .entity(CommandsQuickSearchSession, [GlobalContextService])
.entity(DocsQuickSearchSession, [ .entity(DocsQuickSearchSession, [
WorkspaceService,
DocsSearchService, DocsSearchService,
DocsService, DocsService,
DocDisplayMetaService, DocDisplayMetaService,

View File

@ -8,7 +8,7 @@ export type QuickSearchFunction<S, P> = (
export interface QuickSearchSession<S, P> { export interface QuickSearchSession<S, P> {
items$: LiveData<QuickSearchItem<S, P>[]>; items$: LiveData<QuickSearchItem<S, P>[]>;
isError$?: LiveData<boolean>; error$?: LiveData<any>;
isLoading$?: LiveData<boolean>; isLoading$?: LiveData<boolean>;
loadingProgress$?: LiveData<number>; loadingProgress$?: LiveData<number>;
hasMore$?: LiveData<boolean>; hasMore$?: LiveData<boolean>;

View File

@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({}); export const root = style({});
@ -195,3 +196,9 @@ export const itemSubtitle = style({
fontWeight: 400, fontWeight: 400,
textAlign: 'justify', textAlign: 'justify',
}); });
export const errorMessage = style({
padding: '0px 8px 8px',
fontSize: cssVar('fontXs'),
color: cssVarV2('status/error'),
});

View File

@ -24,6 +24,7 @@ export const CMDK = ({
className, className,
query, query,
groups: newGroups = [], groups: newGroups = [],
error,
inputLabel, inputLabel,
placeholder, placeholder,
loading: newLoading = false, loading: newLoading = false,
@ -33,6 +34,7 @@ export const CMDK = ({
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
className?: string; className?: string;
query: string; query: string;
error?: ReactNode;
inputLabel?: ReactNode; inputLabel?: ReactNode;
placeholder?: string; placeholder?: string;
loading?: boolean; loading?: boolean;
@ -200,6 +202,7 @@ export const CMDK = ({
</div> </div>
<Command.List ref={listRef} data-opening={opening ? true : undefined}> <Command.List ref={listRef} data-opening={opening ? true : undefined}>
{error && <p className={styles.errorMessage}>{error}</p>}
{groups.map(({ group, items }) => { {groups.map(({ group, items }) => {
return ( return (
<CMDKGroup <CMDKGroup

View File

@ -1,3 +1,4 @@
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
@ -18,6 +19,7 @@ export const QuickSearchContainer = () => {
const loading = useLiveData(quickSearch.isLoading$); const loading = useLiveData(quickSearch.isLoading$);
const loadingProgress = useLiveData(quickSearch.loadingProgress$); const loadingProgress = useLiveData(quickSearch.loadingProgress$);
const items = useLiveData(quickSearch.items$); const items = useLiveData(quickSearch.items$);
const error = useLiveData(quickSearch.error$);
const options = useLiveData(quickSearch.options$); const options = useLiveData(quickSearch.options$);
const i18n = useI18n(); const i18n = useI18n();
@ -79,6 +81,7 @@ export const QuickSearchContainer = () => {
<CMDK <CMDK
query={query} query={query}
groups={groups} groups={groups}
error={error ? UserFriendlyError.fromAny(error).message : null}
loading={loading} loading={loading}
loadingProgress={loadingProgress} loadingProgress={loadingProgress}
onQueryChange={handleChangeQuery} onQueryChange={handleChangeQuery}

View File

@ -1,4 +1,5 @@
import type { TagMeta } from '@affine/core/components/page-list'; import type { TagMeta } from '@affine/core/components/page-list';
import { UserFriendlyError } from '@affine/error';
import { I18n } from '@affine/i18n'; import { I18n } from '@affine/i18n';
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils'; import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
import type { DocMeta } from '@blocksuite/affine/store'; import type { DocMeta } from '@blocksuite/affine/store';
@ -6,14 +7,14 @@ import type {
LinkedMenuGroup, LinkedMenuGroup,
LinkedMenuItem, LinkedMenuItem,
} from '@blocksuite/affine/widgets/linked-doc'; } from '@blocksuite/affine/widgets/linked-doc';
import { CollectionsIcon } from '@blocksuite/icons/lit'; import { CollectionsIcon, WarningIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core'; import { computed, signal } from '@preact/signals-core';
import { Service } from '@toeverything/infra'; import { Service } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import Fuse, { type FuseResultMatch } from 'fuse.js'; import Fuse, { type FuseResultMatch } from 'fuse.js';
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { map } from 'rxjs'; import { catchError, map, of } from 'rxjs';
import type { CollectionMeta, CollectionService } from '../../collection'; import type { CollectionMeta, CollectionService } from '../../collection';
import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocDisplayMetaService } from '../../doc-display-meta';
@ -87,6 +88,7 @@ export class SearchMenuService extends Service {
): LinkedMenuGroup { ): LinkedMenuGroup {
const currentWorkspace = this.workspaceService.workspace; const currentWorkspace = this.workspaceService.workspace;
const rawMetas = currentWorkspace.docCollection.meta.docMetas; const rawMetas = currentWorkspace.docCollection.meta.docMetas;
const loading = signal(true);
const { signal: docsSignal, cleanup: cleanupDocs } = const { signal: docsSignal, cleanup: cleanupDocs } =
createSignalFromObservable( createSignalFromObservable(
this.searchDocs$(query).pipe( this.searchDocs$(query).pipe(
@ -108,7 +110,26 @@ export class SearchMenuService extends Service {
); );
}) })
.filter(m => !!m); .filter(m => !!m);
loading.value = false;
return docs; return docs;
}),
catchError(err => {
loading.value = false;
const userFriendlyError = UserFriendlyError.fromAny(err);
return of([
{
name: html`<span style="color: ${cssVarV2('status/error')}"
>${I18n.t(
`error.${userFriendlyError.name}`,
userFriendlyError.data
)}</span
>`,
key: 'error',
icon: WarningIcon(),
disabled: true,
action: () => {},
},
]);
}) })
), ),
[] []
@ -129,6 +150,7 @@ export class SearchMenuService extends Service {
name: I18n.t('com.affine.editor.at-menu.link-to-doc', { name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
query, query,
}), }),
loading: loading,
items: docsSignal, items: docsSignal,
maxDisplay: MAX_DOCS, maxDisplay: MAX_DOCS,
overflowText, overflowText,
@ -170,7 +192,7 @@ export class SearchMenuService extends Service {
return { return {
id, id,
title, title,
highlights: node.highlights.title[0], highlights: node.highlights?.title?.[0],
}; };
}) })
) )

View File

@ -1,4 +1,3 @@
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { import {
createWorkspaceMutation, createWorkspaceMutation,
@ -7,6 +6,7 @@ import {
getWorkspacesQuery, getWorkspacesQuery,
Permission, Permission,
ServerDeploymentType, ServerDeploymentType,
ServerFeature,
} from '@affine/graphql'; } from '@affine/graphql';
import type { import type {
BlobStorage, BlobStorage,
@ -86,7 +86,6 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider');
class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
private readonly authService: AuthService; private readonly authService: AuthService;
private readonly graphqlService: GraphQLService; private readonly graphqlService: GraphQLService;
private readonly featureFlagService: FeatureFlagService;
private readonly unsubscribeAccountChanged: () => void; private readonly unsubscribeAccountChanged: () => void;
constructor( constructor(
@ -95,7 +94,6 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
) { ) {
this.authService = server.scope.get(AuthService); this.authService = server.scope.get(AuthService);
this.graphqlService = server.scope.get(GraphQLService); this.graphqlService = server.scope.get(GraphQLService);
this.featureFlagService = server.scope.get(FeatureFlagService);
this.unsubscribeAccountChanged = this.server.scope.eventBus.on( this.unsubscribeAccountChanged = this.server.scope.eventBus.on(
AccountChanged, AccountChanged,
() => { () => {
@ -477,24 +475,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
id: `${this.flavour}:${workspaceId}`, id: `${this.flavour}:${workspaceId}`,
}, },
}, },
indexer: this.featureFlagService.flags.enable_cloud_indexer.value indexer: {
? { name: 'IndexedDBIndexerStorage',
name: 'CloudIndexerStorage', opts: {
opts: { flavour: this.flavour,
flavour: this.flavour, type: 'workspace',
type: 'workspace', id: workspaceId,
id: workspaceId, },
serverBaseUrl: this.server.serverMetadata.baseUrl, },
},
}
: {
name: 'IndexedDBIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
indexerSync: { indexerSync: {
name: 'IndexedDBIndexerSyncStorage', name: 'IndexedDBIndexerSyncStorage',
opts: { opts: {
@ -535,6 +523,19 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
ServerDeploymentType.Selfhosted, ServerDeploymentType.Selfhosted,
}, },
}, },
indexer: this.server.config$.value.features.includes(
ServerFeature.Indexer
)
? {
name: 'CloudIndexerStorage',
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
}
: undefined,
}, },
v1: { v1: {
doc: this.DocStorageV1Type doc: this.DocStorageV1Type

View File

@ -308,5 +308,7 @@ export async function enableCloudWorkspaceFromShareButton(page: Page) {
export async function enableShare(page: Page) { export async function enableShare(page: Page) {
await page.getByTestId('cloud-share-menu-button').click(); await page.getByTestId('cloud-share-menu-button').click();
await page.getByTestId('share-link-menu-trigger').click(); await page.getByTestId('share-link-menu-trigger').click();
// wait for the menu to be visible
await page.waitForTimeout(500);
await page.getByTestId('share-link-menu-enable-share').click(); await page.getByTestId('share-link-menu-enable-share').click();
} }