feat(core): integrate realtime features (#15003)

This commit is contained in:
DarkSky
2026-05-20 05:48:03 +08:00
committed by GitHub
parent 3e42bbf4fa
commit 9f33d37add
108 changed files with 3069 additions and 2729 deletions
@@ -3,6 +3,7 @@ import { AuthService } from '@affine/core/modules/cloud/services/auth';
import { FetchService } from '@affine/core/modules/cloud/services/fetch';
import { AuthStore } from '@affine/core/modules/cloud/stores/auth';
import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog';
import { NbstoreService } from '@affine/core/modules/storage';
import { UrlService } from '@affine/core/modules/url/services/url';
import { Framework } from '@toeverything/infra';
import { of } from 'rxjs';
@@ -37,12 +38,16 @@ describe('AuthService oauthPreflight', () => {
} as any);
framework.service(UrlService, { getClientScheme: () => null } as any);
framework.service(GlobalDialogService, { open: vi.fn() } as any);
framework.service(NbstoreService, {
realtime: { subscribe: () => of() },
} as any);
framework.service(AuthService, [
FetchService,
AuthStore,
UrlService,
GlobalDialogService,
NbstoreService,
]);
const auth = framework.provider().get(AuthService);
@@ -31,4 +31,22 @@ describe('CopilotClient action streams', () => {
'/api/copilot/actions/session-1/stream?messageId=message-1&actionId=mindmap.generate&actionVersion=v1&runId=run-1&retry=true'
);
});
test('loads embedding status through realtime request', async () => {
const gql = vi.fn();
const request = vi.fn().mockResolvedValue({ total: 4, embedded: 2 });
const client = new CopilotClient(gql, vi.fn(), { request });
await expect(client.getEmbeddingStatus('workspace-1')).resolves.toEqual({
total: 4,
embedded: 2,
});
expect(gql).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
'workspace.embedding.progress.get',
{ workspaceId: 'workspace-1' },
{ timeoutMs: 10000 }
);
});
});
@@ -1,5 +1,6 @@
import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required';
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
import type { NbstoreService } from '@affine/core/modules/storage';
import { UserFriendlyError } from '@affine/error';
import {
addContextBlobMutation,
@@ -17,7 +18,6 @@ import {
getCopilotRecentSessionsQuery,
getCopilotSessionQuery,
getCopilotSessionsQuery,
getWorkspaceEmbeddingStatusQuery,
type GraphQLQuery,
listContextObjectQuery,
listContextQuery,
@@ -98,7 +98,8 @@ export class CopilotClient {
readonly eventSource: (
url: string,
eventSourceInitDict?: EventSourceInit
) => EventSource
) => EventSource,
readonly realtime?: Pick<NbstoreService['realtime'], 'request'>
) {}
async createSession(
@@ -546,11 +547,15 @@ export class CopilotClient {
return queryString.toString();
}
getEmbeddingStatus(workspaceId: string) {
return this.gql({
query: getWorkspaceEmbeddingStatusQuery,
variables: { workspaceId },
}).then(res => res.queryWorkspaceEmbeddingStatus);
async getEmbeddingStatus(workspaceId: string) {
if (!this.realtime) {
throw new Error('Realtime client is required');
}
return await this.realtime.request(
'workspace.embedding.progress.get',
{ workspaceId },
{ timeoutMs: 10000 }
);
}
addContextBlob(options: OptionsField<typeof addContextBlobMutation>) {
@@ -1,3 +1,4 @@
import type { NbstoreService } from '@affine/core/modules/storage';
import {
ContextCategories,
type CopilotChatHistoryFragment,
@@ -390,7 +391,8 @@ export function createAIRequestService(
eventSource: (
url: string,
eventSourceInitDict?: EventSourceInit
) => EventSource
) => EventSource,
realtime: Pick<NbstoreService['realtime'], 'request'>
) {
return new AIRequestService(new CopilotClient(gql, eventSource));
return new AIRequestService(new CopilotClient(gql, eventSource, realtime));
}
@@ -24,6 +24,7 @@ import { DocsService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useRegisterNavigationCommands } from '@affine/core/modules/navigation/view/use-register-navigation-commands';
import { QuickSearchContainer } from '@affine/core/modules/quicksearch';
import { NbstoreService } from '@affine/core/modules/storage';
import { WorkbenchService } from '@affine/core/modules/workbench';
import {
getAFFiNEWorkspaceSchema,
@@ -140,12 +141,14 @@ export const WorkspaceSideEffects = () => {
const graphqlService = useService(GraphQLService);
const eventSourceService = useService(EventSourceService);
const authService = useService(AuthService);
const nbstoreService = useService(NbstoreService);
useEffect(() => {
const dispose = setupAIProvider(
createAIRequestService(
graphqlService.gql,
eventSourceService.eventSource
eventSourceService.eventSource,
nbstoreService.realtime
),
globalDialogService,
authService
@@ -155,6 +158,7 @@ export const WorkspaceSideEffects = () => {
};
}, [
eventSourceService,
nbstoreService,
workspaceDialogService,
graphqlService,
globalDialogService,
@@ -46,13 +46,12 @@ const McpServerSetting = () => {
return accessTokens?.find(token => token.name === 'mcp');
}, [accessTokens]);
const displayedToken = revealedAccessToken ?? mcpAccessToken;
const hasMcpToken = Boolean(revealedAccessToken || mcpAccessToken);
const hasCopyableToken = Boolean(revealedAccessToken);
const isRedactedDisplay = hasMcpToken && !hasCopyableToken;
const code = useMemo(() => {
return displayedToken
return revealedAccessToken
? JSON.stringify(
{
mcpServers: {
@@ -61,7 +60,7 @@ const McpServerSetting = () => {
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`,
note: `Read docs from AFFiNE workspace "${workspaceName}"`,
headers: {
Authorization: `Bearer ${displayedToken.token}`,
Authorization: `Bearer ${revealedAccessToken.token}`,
},
},
},
@@ -70,7 +69,7 @@ const McpServerSetting = () => {
2
)
: null;
}, [displayedToken, workspaceName, workspaceService, serverService]);
}, [revealedAccessToken, workspaceName, workspaceService, serverService]);
const copyJsonDisabled = !code || mutating || isRedactedDisplay;
const copyJsonTooltip = isRedactedDisplay
@@ -31,6 +31,7 @@ import {
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { NbstoreService } from '@affine/core/modules/storage';
import { AppThemeService } from '@affine/core/modules/theme';
import {
ViewBody,
@@ -55,14 +56,16 @@ import * as styles from './index.css';
function useAIRequestService() {
const graphqlService = useService(GraphQLService);
const eventSourceService = useService(EventSourceService);
const nbstoreService = useService(NbstoreService);
return useMemo(
() =>
createAIRequestService(
graphqlService.gql,
eventSourceService.eventSource
eventSourceService.eventSource,
nbstoreService.realtime
),
[graphqlService, eventSourceService]
[graphqlService, eventSourceService, nbstoreService]
);
}
@@ -36,6 +36,7 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { useSignalValue } from '@affine/core/modules/doc-info/utils';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { NbstoreService } from '@affine/core/modules/storage';
import { AppThemeService } from '@affine/core/modules/theme';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
@@ -75,6 +76,7 @@ export const EditorChatPanel = ({
const framework = useFramework();
const graphqlService = useService(GraphQLService);
const eventSourceService = useService(EventSourceService);
const nbstoreService = useService(NbstoreService);
const workbench = useService(WorkbenchService).workbench;
const t = useI18n();
@@ -108,9 +110,10 @@ export const EditorChatPanel = ({
() =>
createAIRequestService(
graphqlService.gql,
eventSourceService.eventSource
eventSourceService.eventSource,
nbstoreService.realtime
),
[eventSourceService.eventSource, graphqlService.gql]
[eventSourceService.eventSource, graphqlService.gql, nbstoreService]
);
const [pendingSessionId] = useState(() => {
@@ -12,8 +12,8 @@ import {
} from '@toeverything/infra';
import { map, tap } from 'rxjs';
import { mapRealtimeEnum } from '../realtime/enum';
import type { AuthService } from '../services/auth';
import type { UserFeatureStore } from '../stores/user-feature';
export class UserFeature extends Entity {
// undefined means no user, null means loading
@@ -32,10 +32,7 @@ export class UserFeature extends Entity {
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
constructor(
private readonly authService: AuthService,
private readonly store: UserFeatureStore
) {
constructor(private readonly authService: AuthService) {
super();
}
@@ -44,23 +41,20 @@ export class UserFeature extends Entity {
accountId: this.authService.session.account$.value?.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.accountId === b.accountId,
() => false,
({ accountId }) => {
return fromPromise(async signal => {
return fromPromise(async () => {
if (!accountId) {
return; // no feature if no user
}
const { userId, features } = await this.store.getUserFeatures(signal);
if (userId !== accountId) {
// The user has changed, ignore the result
this.authService.session.revalidate();
await this.authService.session.waitForRevalidation();
return;
}
const account = this.authService.session.account$.value;
if (account?.id !== accountId) return;
return {
userId: userId,
features: features,
userId: account.id,
features: account.info?.features?.map(feature =>
mapRealtimeEnum(FeatureType, feature, 'user feature')
),
};
}).pipe(
smartRetry(),
@@ -1,4 +1,3 @@
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
import type { UserQuotaStateSnapshot } from '@affine/realtime';
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
@@ -8,8 +7,6 @@ import { AuthService } from '../services/auth';
import { UserQuotaStore } from '../stores/user-quota';
import { UserQuota } from './user-quota';
type Quota = NonNullable<GetCurrentUserProfileQuery['currentUser']>['quota'];
const authService = {
session: {
['account$']: {
@@ -41,24 +38,6 @@ function createQuotaState(
};
}
function createQuota(overrides: Partial<Quota> = {}): Quota {
return {
name: 'Legacy',
blobLimit: 1024,
storageQuota: 2048,
historyPeriod: 30 * 24 * 60 * 60,
memberLimit: 8,
humanReadable: {
name: 'Legacy',
blobLimit: '1 KB',
storageQuota: '2 KB',
historyPeriod: '30 days',
memberLimit: '8',
},
...overrides,
};
}
function createStore(
overrides: Partial<UserQuotaStore>,
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
@@ -66,7 +45,6 @@ function createStore(
return {
fetchUserQuotaState: vi.fn(),
subscribeUserQuotaState: vi.fn(() => eventSubject),
fetchUserQuota: vi.fn(),
...overrides,
} as unknown as UserQuotaStore;
}
@@ -103,24 +81,20 @@ describe('UserQuota', () => {
await vi.waitFor(() => expect(quota.used$.value).toBe(768));
expect(store.fetchUserQuotaState).toHaveBeenCalledTimes(2);
expect(store.fetchUserQuota).not.toHaveBeenCalled();
quota.dispose();
});
test('falls back to legacy GraphQL quota when realtime request fails', async () => {
test('surfaces realtime quota errors without GraphQL fallback', async () => {
const error = new Error('offline');
const store = createStore({
fetchUserQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
fetchUserQuota: vi.fn().mockResolvedValue({
quota: createQuota(),
used: 256,
}),
fetchUserQuotaState: vi.fn().mockRejectedValue(error),
});
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
expect(quota.used$.value).toBe(256);
await vi.waitFor(() => expect(quota.error$.value).toBe(error));
expect(quota.quota$.value).toBeNull();
quota.dispose();
});
});
@@ -1,4 +1,3 @@
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
import type {
RealtimeTopicEventOf,
UserQuotaStateSnapshot,
@@ -11,9 +10,20 @@ import { RealtimeLiveQuery } from '../realtime/live-query';
import type { AuthService } from '../services/auth';
import type { UserQuotaStore } from '../stores/user-quota';
type QuotaType = NonNullable<
GetCurrentUserProfileQuery['currentUser']
>['quota'];
type QuotaType = {
name: string;
blobLimit: number;
storageQuota: number;
historyPeriod: number;
memberLimit: number;
humanReadable: {
name: string;
blobLimit: string;
storageQuota: string;
historyPeriod: string;
memberLimit: string;
};
};
const DAY_SECONDS = 24 * 60 * 60;
@@ -159,9 +169,6 @@ export class UserQuota extends Entity {
quota: userQuotaFromState(state),
used: state.usedStorageQuota,
};
} catch {
const { quota, used } = await this.store.fetchUserQuota(signal);
return { quota, used };
} finally {
this.isRevalidating$.setValue(false);
}
@@ -101,7 +101,6 @@ import { ServerConfigStore } from './stores/server-config';
import { ServerListStore } from './stores/server-list';
import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
import { UserFeatureStore } from './stores/user-feature';
import { UserQuotaStore } from './stores/user-quota';
import { UserSettingsStore } from './stores/user-settings';
import { DocCreatedByService } from './services/doc-created-by';
@@ -146,6 +145,7 @@ export function configureCloudModule(framework: Framework) {
AuthStore,
UrlService,
GlobalDialogService,
NbstoreService,
])
.store(AuthStore, [
FetchService,
@@ -153,6 +153,7 @@ export function configureCloudModule(framework: Framework) {
GlobalState,
ServerService,
AuthProvider,
NbstoreService,
])
.entity(AuthSession, [AuthStore])
.service(SubscriptionService, [SubscriptionStore])
@@ -165,7 +166,7 @@ export function configureCloudModule(framework: Framework) {
.entity(Subscription, [AuthService, ServerService, SubscriptionStore])
.entity(SubscriptionPrices, [ServerService, SubscriptionStore])
.service(UserQuotaService)
.store(UserQuotaStore, [GraphQLService, NbstoreService])
.store(UserQuotaStore, [NbstoreService])
.entity(UserQuota, [AuthService, UserQuotaStore])
.service(UserCopilotQuotaService)
.store(UserCopilotQuotaStore, [GraphQLService])
@@ -175,8 +176,7 @@ export function configureCloudModule(framework: Framework) {
ServerService,
])
.service(UserFeatureService)
.entity(UserFeature, [AuthService, UserFeatureStore])
.store(UserFeatureStore, [GraphQLService])
.entity(UserFeature, [AuthService])
.service(InvoicesService)
.store(InvoicesStore, [GraphQLService])
.entity(Invoices, [InvoicesStore])
@@ -188,9 +188,9 @@ export function configureCloudModule(framework: Framework) {
.service(PublicUserService, [PublicUserStore])
.store(PublicUserStore, [GraphQLService])
.service(UserSettingsService, [UserSettingsStore])
.store(UserSettingsStore, [GraphQLService])
.store(UserSettingsStore, [GraphQLService, NbstoreService])
.service(AccessTokenService, [AccessTokenStore])
.store(AccessTokenStore, [GraphQLService]);
.store(AccessTokenStore, [GraphQLService, NbstoreService]);
framework
.scope(WorkspaceScope)
@@ -0,0 +1,10 @@
export function mapRealtimeEnum<T extends Record<string, string>>(
enumType: T,
value: string,
label: string
): T[keyof T] {
if (Object.prototype.hasOwnProperty.call(enumType, value)) {
return enumType[value as keyof T];
}
throw new Error(`Unknown ${label}: ${value}`);
}
@@ -0,0 +1,54 @@
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
import { AccessTokenStore } from '../stores/access-token';
import { AccessTokenService } from './access-token';
function createStore() {
return {
subscribeUserAccessTokens: vi.fn(() => new Subject()),
listUserAccessTokens: vi.fn().mockResolvedValue([
{
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
},
]),
generateUserAccessToken: vi.fn().mockResolvedValue({
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
token: 'secret-token',
}),
} as unknown as AccessTokenStore;
}
describe('AccessTokenService', () => {
test('does not store generated plaintext token in the long-lived list', async () => {
const framework = new Framework();
framework
.store(AccessTokenStore, createStore())
.service(AccessTokenService, [AccessTokenStore]);
const service = framework.provider().get(AccessTokenService);
const accessToken = await service.generateUserAccessToken('MCP');
expect(accessToken.token).toBe('secret-token');
expect(service.accessTokens$.value).toEqual([
{
id: 'token-1',
name: 'MCP',
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: null,
},
]);
expect(JSON.stringify(service.accessTokens$.value)).not.toContain(
'secret-token'
);
service.dispose();
});
});
@@ -1,40 +1,48 @@
import {
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
OnEvent,
onStart,
Service,
smartRetry,
} from '@toeverything/infra';
import { catchError, EMPTY, tap } from 'rxjs';
import { LiveData, OnEvent, Service } from '@toeverything/infra';
import { AccountChanged } from '../events/account-changed';
import type { AccessToken, AccessTokenStore } from '../stores/access-token';
import { RealtimeLiveQuery } from '../realtime/live-query';
import type {
AccessToken,
AccessTokenStore,
ListedAccessToken,
} from '../stores/access-token';
@OnEvent(AccountChanged, e => e.onAccountChanged)
export class AccessTokenService extends Service {
constructor(private readonly accessTokenStore: AccessTokenStore) {
super();
this.liveQuery.start();
}
accessTokens$ = new LiveData<AccessToken[] | null>(null);
accessTokens$ = new LiveData<ListedAccessToken[] | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any>(null);
private readonly liveQuery = new RealtimeLiveQuery({
request: signal => this.requestAccessTokens(signal),
subscribe: () => this.accessTokenStore.subscribeUserAccessTokens(),
applySnapshot: accessTokens => {
this.error$.value = null;
this.accessTokens$.value = accessTokens;
},
applyEvent: () => 'revalidate' as const,
onError: error => {
this.error$.value = error;
},
});
async generateUserAccessToken(name: string): Promise<AccessToken> {
const accessToken =
await this.accessTokenStore.generateUserAccessToken(name);
const { token: _token, ...listedAccessToken } = accessToken;
this.accessTokens$.value = [
...(this.accessTokens$.value || []),
accessToken as AccessToken,
listedAccessToken,
];
await this.waitForRevalidation();
return accessToken as AccessToken;
return accessToken;
}
async revokeUserAccessToken(id: string) {
@@ -44,28 +52,9 @@ export class AccessTokenService extends Service {
await this.waitForRevalidation();
}
revalidate = effect(
exhaustMapWithTrailing(() => {
return fromPromise(() => {
return this.accessTokenStore.listUserAccessTokens();
}).pipe(
smartRetry(),
tap(accessTokens => {
this.accessTokens$.value = accessTokens;
}),
catchError(error => {
this.error$.value = error;
return EMPTY;
}),
onStart(() => {
this.isRevalidating$.value = true;
}),
onComplete(() => {
this.isRevalidating$.value = false;
})
);
})
);
revalidate = () => {
this.liveQuery.revalidate();
};
private onAccountChanged() {
this.accessTokens$.value = null;
@@ -79,4 +68,18 @@ export class AccessTokenService extends Service {
signal
);
}
override dispose(): void {
super.dispose();
this.liveQuery.dispose();
}
private async requestAccessTokens(signal: AbortSignal) {
this.isRevalidating$.value = true;
try {
return await this.accessTokenStore.listUserAccessTokens(signal);
} finally {
this.isRevalidating$.value = false;
}
}
}
@@ -3,10 +3,11 @@ import type { OAuthProviderType } from '@affine/graphql';
import { track } from '@affine/track';
import { OnEvent, Service } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { distinctUntilChanged, map, skip } from 'rxjs';
import { distinctUntilChanged, map, skip, type Subscription } from 'rxjs';
import type { GlobalDialogService } from '../../dialogs';
import { ApplicationFocused } from '../../lifecycle';
import type { NbstoreService } from '../../storage';
import type { UrlService } from '../../url';
import { AuthSession } from '../entities/session';
import { AccountChanged } from '../events/account-changed';
@@ -20,12 +21,14 @@ import type { FetchService } from './fetch';
@OnEvent(ServerStarted, e => e.onServerStarted)
export class AuthService extends Service {
session = this.framework.createEntity(AuthSession);
private profileSubscription?: Subscription;
constructor(
private readonly fetchService: FetchService,
private readonly store: AuthStore,
private readonly urlService: UrlService,
private readonly dialogService: GlobalDialogService
private readonly dialogService: GlobalDialogService,
private readonly nbstoreService: NbstoreService
) {
super();
@@ -41,21 +44,53 @@ export class AuthService extends Service {
.subscribe(({ account }) => {
if (account === null) {
this.eventBus.emit(AccountLoggedOut, account);
this.profileSubscription?.unsubscribe();
this.profileSubscription = undefined;
} else {
this.eventBus.emit(AccountLoggedIn, account);
this.subscribeProfile();
}
this.eventBus.emit(AccountChanged, account);
});
this.subscribeProfile();
}
private onServerStarted() {
this.session.revalidate();
this.subscribeProfile();
}
private onApplicationFocused() {
this.session.revalidate();
}
private subscribeProfile() {
this.profileSubscription?.unsubscribe();
this.profileSubscription = undefined;
if (!this.session.account$.value) return;
this.profileSubscription = this.nbstoreService.realtime
.subscribe('user.profile.changed', {})
.subscribe({
next: () => {
void (async () => {
this.session.revalidate();
await this.session.waitForRevalidation();
this.eventBus.emit(AccountChanged, this.session.account$.value);
})().catch(() => {});
},
error: () => {
this.profileSubscription = undefined;
},
});
}
override dispose() {
super.dispose();
this.profileSubscription?.unsubscribe();
this.profileSubscription = undefined;
}
async sendEmailMagicLink(
email: string,
verifyToken?: string,
@@ -1,15 +1,6 @@
import {
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
smartRetry,
} from '@toeverything/infra';
import { catchError, EMPTY, tap } from 'rxjs';
import { LiveData, Service } from '@toeverything/infra';
import { RealtimeLiveQuery } from '../realtime/live-query';
import type {
UpdateUserSettingsInput,
UserSettings,
@@ -21,34 +12,28 @@ export type { UserSettings };
export class UserSettingsService extends Service {
constructor(private readonly store: UserSettingsStore) {
super();
this.liveQuery.start();
}
userSettings$ = new LiveData<UserSettings | undefined>(undefined);
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<any | undefined>(undefined);
private readonly liveQuery = new RealtimeLiveQuery({
request: signal => this.requestUserSettings(signal),
subscribe: () => this.store.subscribeUserSettings(),
applySnapshot: settings => {
this.error$.value = undefined;
this.userSettings$.value = settings;
},
applyEvent: () => 'revalidate' as const,
onError: error => {
this.error$.value = error;
},
});
revalidate = effect(
exhaustMapWithTrailing(() => {
return fromPromise(() => {
return this.store.getUserSettings();
}).pipe(
smartRetry(),
tap(settings => {
this.userSettings$.value = settings;
}),
catchError(error => {
this.error$.value = error;
return EMPTY;
}),
onStart(() => {
this.isLoading$.value = true;
}),
onComplete(() => {
this.isLoading$.value = false;
})
);
})
);
revalidate = () => {
this.liveQuery.revalidate();
};
async updateUserSettings(settings: UpdateUserSettingsInput) {
await this.store.updateUserSettings(settings);
@@ -58,4 +43,18 @@ export class UserSettingsService extends Service {
};
this.revalidate();
}
override dispose(): void {
super.dispose();
this.liveQuery.dispose();
}
private async requestUserSettings(signal: AbortSignal) {
this.isLoading$.value = true;
try {
return await this.store.getUserSettings(signal);
} finally {
this.isLoading$.value = false;
}
}
}
@@ -1,29 +1,40 @@
import {
generateUserAccessTokenMutation,
type ListUserAccessTokensQuery,
listUserAccessTokensQuery,
revokeUserAccessTokenMutation,
} from '@affine/graphql';
import type { AccessTokenSnapshot } from '@affine/realtime';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { GraphQLService } from '../services/graphql';
export type AccessToken = NonNullable<
ListUserAccessTokensQuery['currentUser']
>['revealedAccessTokens'][number];
export type AccessToken = AccessTokenSnapshot & { token: string };
export type ListedAccessToken = AccessTokenSnapshot;
export class AccessTokenStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
constructor(
private readonly gqlService: GraphQLService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async listUserAccessTokens(signal?: AbortSignal): Promise<AccessToken[]> {
const data = await this.gqlService.gql({
query: listUserAccessTokensQuery,
context: { signal },
});
async listUserAccessTokens(
signal?: AbortSignal
): Promise<ListedAccessToken[]> {
const { tokens } = await this.nbstoreService.realtime.request(
'user.access-tokens.get',
{},
{ signal, timeoutMs: 10000 }
);
return tokens;
}
return data.currentUser?.revealedAccessTokens ?? [];
subscribeUserAccessTokens() {
return this.nbstoreService.realtime.subscribe(
'user.access-tokens.changed',
{}
);
}
async generateUserAccessToken(
@@ -1,12 +1,13 @@
import {
deleteAccountMutation,
removeAvatarMutation,
ServerDeploymentType,
updateUserProfileMutation,
uploadAvatarMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GlobalState } from '../../storage';
import type { GlobalState, NbstoreService } from '../../storage';
import type { AuthSessionInfo } from '../entities/session';
import type { AuthProvider } from '../provider/auth';
import type { FetchService } from '../services/fetch';
@@ -20,6 +21,7 @@ export interface AccountProfile {
hasPassword: boolean;
avatarUrl: string | null;
emailVerified: string | null;
features?: string[];
}
export class AuthStore extends Store {
@@ -28,7 +30,8 @@ export class AuthStore extends Store {
private readonly gqlService: GraphQLService,
private readonly globalState: GlobalState,
private readonly serverService: ServerService,
private readonly authProvider: AuthProvider
private readonly authProvider: AuthProvider,
private readonly nbstoreService: NbstoreService
) {
super();
}
@@ -58,20 +61,20 @@ export class AuthStore extends Store {
}
async fetchSession() {
const url = `/api/auth/session`;
const options: RequestInit = {
headers: {
'Content-Type': 'application/json',
},
const { user } = await this.nbstoreService.realtime.request(
'user.profile.get',
{},
{ timeoutMs: 10000 }
);
return {
user: user
? {
...user,
hasPassword: Boolean(user.hasPassword),
emailVerified: user.emailVerified ? 'true' : null,
}
: null,
};
const res = await this.fetchService.fetch(url, options);
const data = (await res.json()) as {
user?: AccountProfile | null;
};
if (!res.ok)
throw new Error('Get session fetch error: ' + JSON.stringify(data));
return data; // Return null if data empty
}
async signInMagicLink(email: string, token: string) {
@@ -102,6 +105,13 @@ export class AuthStore extends Store {
async signOut() {
await this.authProvider.signOut();
await this.nbstoreService.realtime.configure({
endpoint: this.serverService.server.baseUrl,
authenticated: false,
isSelfHosted:
this.serverService.server.config$.value.type ===
ServerDeploymentType.Selfhosted,
});
}
async uploadAvatar(file: File) {
@@ -1,4 +1,4 @@
import { getCurrentUserProfileQuery } from '@affine/graphql';
import { copilotQuotaQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
@@ -10,7 +10,7 @@ export class UserCopilotQuotaStore extends Store {
async fetchUserCopilotQuota(abortSignal?: AbortSignal) {
const data = await this.graphqlService.gql({
query: getCurrentUserProfileQuery,
query: copilotQuotaQuery,
context: {
signal: abortSignal,
},
@@ -1,23 +0,0 @@
import { getCurrentUserProfileQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class UserFeatureStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async getUserFeatures(signal: AbortSignal) {
const data = await this.gqlService.gql({
query: getCurrentUserProfileQuery,
context: {
signal,
},
});
return {
userId: data.currentUser?.id,
features: data.currentUser?.features,
};
}
}
@@ -1,14 +1,9 @@
import { getCurrentUserProfileQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { GraphQLService } from '../services/graphql';
export class UserQuotaStore extends Store {
constructor(
private readonly graphqlService: GraphQLService,
private readonly nbstoreService: NbstoreService
) {
constructor(private readonly nbstoreService: NbstoreService) {
super();
}
@@ -27,23 +22,4 @@ export class UserQuotaStore extends Store {
{}
);
}
async fetchUserQuota(abortSignal?: AbortSignal) {
const data = await this.graphqlService.gql({
query: getCurrentUserProfileQuery,
context: {
signal: abortSignal,
},
});
if (!data.currentUser) {
throw new Error('No logged in');
}
return {
userId: data.currentUser.id,
quota: data.currentUser.quota,
used: data.currentUser.quotaUsage.storageQuota,
};
}
}
@@ -1,29 +1,38 @@
import {
type GetCurrentUserProfileQuery,
getCurrentUserProfileQuery,
type UpdateUserSettingsInput,
updateUserSettingsMutation,
} from '@affine/graphql';
import type { UserSettingsSnapshot } from '@affine/realtime';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { GraphQLService } from '../services/graphql';
export type UserSettings = NonNullable<
GetCurrentUserProfileQuery['currentUser']
>['settings'];
export type UserSettings = UserSettingsSnapshot;
export type { UpdateUserSettingsInput };
export class UserSettingsStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
constructor(
private readonly gqlService: GraphQLService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async getUserSettings(): Promise<UserSettings | undefined> {
const result = await this.gqlService.gql({
query: getCurrentUserProfileQuery,
});
return result.currentUser?.settings;
async getUserSettings(
signal?: AbortSignal
): Promise<UserSettings | undefined> {
const { settings } = await this.nbstoreService.realtime.request(
'user.settings.get',
{},
{ signal, timeoutMs: 10000 }
);
return settings;
}
subscribeUserSettings() {
return this.nbstoreService.realtime.subscribe('user.settings.changed', {});
}
async updateUserSettings(settings: UpdateUserSettingsInput) {
@@ -150,7 +150,8 @@ export class DocCommentStore extends Entity<{
return {
changes: commentChanges.changes.map(change => ({
id: change.id,
action: change.action,
action:
change.action as DocCommentChangeListResult['changes'][number]['action'],
comment: normalizeComment(change.item as GQLCommentType),
commentId: change.commentId ?? undefined,
})),
@@ -1,21 +1,22 @@
import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql';
import {
catchErrorInto,
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import { map, switchMap, tap } from 'rxjs';
import type { WorkspaceMemberStatus } from '@affine/graphql';
import type {
RealtimeTopicEventOf,
WorkspaceMemberSnapshot,
} from '@affine/realtime';
import { Entity, LiveData } from '@toeverything/infra';
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
import type { WorkspaceService } from '../../workspace';
import type { WorkspaceMembersStore } from '../stores/members';
export type Member =
GetMembersByWorkspaceIdQuery['workspace']['members'][number];
export type Member = Omit<
WorkspaceMemberSnapshot,
'permission' | 'role' | 'status'
> & {
permission: string;
role: string;
status: WorkspaceMemberStatus;
};
export class WorkspaceMembers extends Entity {
constructor(
@@ -23,6 +24,7 @@ export class WorkspaceMembers extends Entity {
private readonly workspaceService: WorkspaceService
) {
super();
this.liveQuery.start();
}
pageNum$ = new LiveData(0);
@@ -34,37 +36,48 @@ export class WorkspaceMembers extends Entity {
readonly PAGE_SIZE = 8;
readonly revalidate = effect(
map(() => this.pageNum$.value),
switchMap(pageNum => {
return fromPromise(async signal => {
return this.store.fetchMembers(
this.workspaceService.workspace.id,
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
);
}).pipe(
tap(data => {
this.memberCount$.setValue(data.memberCount);
this.pageMembers$.setValue(data.members);
}),
smartRetry(),
catchErrorInto(this.error$),
onStart(() => {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
private readonly liveQuery = new RealtimeLiveQuery<
{ members: Member[]; memberCount: number },
RealtimeTopicEventOf<'workspace.members.changed'>
>({
request: signal => this.requestMembers(signal),
subscribe: () =>
this.store.subscribeMembers(this.workspaceService.workspace.id),
applySnapshot: data => {
this.error$.next(null);
this.memberCount$.setValue(data.memberCount);
this.pageMembers$.setValue(data.members);
},
applyEvent: () => 'revalidate',
onError: error => this.error$.setValue(error),
});
revalidate = () => {
this.liveQuery.revalidate();
};
setPageNum(pageNum: number) {
this.pageNum$.setValue(pageNum);
this.revalidate();
}
override dispose(): void {
this.revalidate.unsubscribe();
this.liveQuery.dispose();
}
private async requestMembers(signal: AbortSignal) {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true);
try {
const pageNum = this.pageNum$.value;
return await this.store.fetchMembers(
this.workspaceService.workspace.id,
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
);
} finally {
this.isLoading$.setValue(false);
}
}
}
@@ -8,6 +8,7 @@ import {
onComplete,
onStart,
} from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { tap } from 'rxjs';
import type { WorkspaceService } from '../../workspace';
@@ -25,12 +26,24 @@ export class WorkspacePermission extends Entity {
);
isTeam$ = this.cache$.map(cache => cache?.isTeam ?? null);
isRevalidating$ = new LiveData(false);
private readonly subscription?: Subscription;
constructor(
private readonly workspaceService: WorkspaceService,
private readonly store: WorkspacePermissionStore
) {
super();
if (
this.workspaceService.workspace.flavour !== 'local' &&
!this.workspaceService.workspace.openOptions.isSharedMode
) {
this.subscription = this.store
.subscribeWorkspaceAccess(this.workspaceService.workspace.id)
.subscribe({
next: () => this.revalidate(),
error: () => {},
});
}
}
revalidate = effect(
@@ -82,5 +95,6 @@ export class WorkspacePermission extends Entity {
override dispose(): void {
this.revalidate.unsubscribe();
this.subscription?.unsubscribe();
}
}
@@ -16,6 +16,7 @@ import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud/services/workspace-server';
import { DocScope, DocService } from '../doc';
import { NbstoreService } from '../storage';
import {
WorkspaceLocalState,
WorkspaceScope,
@@ -46,19 +47,24 @@ export function configurePermissionsModule(framework: Framework) {
.store(WorkspacePermissionStore, [
WorkspaceServerService,
WorkspaceLocalState,
NbstoreService,
])
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
.store(WorkspaceMembersStore, [WorkspaceServerService])
.store(WorkspaceMembersStore, [WorkspaceServerService, NbstoreService])
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
.store(MemberSearchStore, [WorkspaceServerService])
.store(MemberSearchStore, [NbstoreService])
.service(GuardService, [
GuardStore,
WorkspaceService,
WorkspacePermissionService,
])
.store(GuardStore, [WorkspaceService, WorkspaceServerService]);
.store(GuardStore, [
WorkspaceService,
WorkspaceServerService,
NbstoreService,
]);
framework
.scope(WorkspaceScope)
@@ -68,5 +74,5 @@ export function configurePermissionsModule(framework: Framework) {
WorkspaceService,
DocService,
])
.store(DocGrantedUsersStore, [WorkspaceServerService]);
.store(DocGrantedUsersStore, [WorkspaceServerService, NbstoreService]);
}
@@ -1,4 +1,5 @@
import { DocRole, type GetPageGrantedUsersListQuery } from '@affine/graphql';
import { DocRole } from '@affine/graphql';
import type { DocGrantedUserSnapshot } from '@affine/realtime';
import {
catchErrorInto,
effect,
@@ -9,14 +10,16 @@ import {
Service,
smartRetry,
} from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { EMPTY, exhaustMap, tap } from 'rxjs';
import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace';
import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
export type GrantedUser =
GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node'];
export type GrantedUser = Omit<DocGrantedUserSnapshot, 'role'> & {
role: DocRole;
};
export class DocGrantedUsersService extends Service {
constructor(
@@ -25,8 +28,22 @@ export class DocGrantedUsersService extends Service {
private readonly docService: DocService
) {
super();
this.subscription = this.store
.subscribeDocGrants(
this.workspaceService.workspace.id,
this.docService.doc.id
)
.subscribe({
next: () => {
this.reset();
this.loadMore();
},
error: error => this.error$.setValue(error),
});
}
private readonly subscription: Subscription;
readonly PAGE_SIZE = 8;
nextCursor$ = new LiveData<string | undefined>(undefined);
@@ -149,5 +166,6 @@ export class DocGrantedUsersService extends Service {
override dispose(): void {
this.loadMore.unsubscribe();
this.subscription.unsubscribe();
}
}
@@ -8,6 +8,7 @@ import {
Service,
smartRetry,
} from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { EMPTY, exhaustMap, tap } from 'rxjs';
import type { WorkspaceService } from '../../workspace';
@@ -20,8 +21,20 @@ export class MemberSearchService extends Service {
private readonly workspaceService: WorkspaceService
) {
super();
this.subscription = this.store
.subscribeMembers(this.workspaceService.workspace.id)
.subscribe({
next: () => {
if (this.searchText$.value) {
this.search(this.searchText$.value);
}
},
error: error => this.error$.setValue(error),
});
}
private readonly subscription: Subscription;
readonly PAGE_SIZE = 8;
readonly searchText$ = new LiveData<string>('');
readonly isLoading$ = new LiveData(false);
@@ -70,4 +83,9 @@ export class MemberSearchService extends Service {
this.searchText$.setValue(searchText ?? '');
this.loadMore();
}
override dispose(): void {
this.loadMore.unsubscribe();
this.subscription.unsubscribe();
}
}
@@ -1,7 +1,5 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
type DocRole,
getPageGrantedUsersListQuery,
type GrantDocUserRolesInput,
grantDocUserRolesMutation,
type PaginationInput,
@@ -12,8 +10,14 @@ import {
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import { mapDocGrantedUserSnapshot } from './realtime-mappers';
export class DocGrantedUsersStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
@@ -23,20 +27,33 @@ export class DocGrantedUsersStore extends Store {
pagination: PaginationInput,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: getPageGrantedUsersListQuery,
variables: {
workspaceId,
docId,
pagination,
},
context: { signal },
});
return await this.nbstoreService.realtime
.request(
'doc.grants.get',
{
workspaceId,
docId,
pagination: {
first: pagination.first ?? 10,
offset: pagination.offset ?? 0,
after: pagination.after ?? undefined,
},
},
{ signal, timeoutMs: 10000 }
)
.then(data => ({
...data,
edges: data.edges.map(edge => ({
node: mapDocGrantedUserSnapshot(edge.node),
})),
}));
}
return res.workspace.doc.grantedUsersList;
subscribeDocGrants(workspaceId: string, docId: string) {
return this.nbstoreService.realtime.subscribe('doc.grants.changed', {
workspaceId,
docId,
});
}
async grantDocUserRoles(input: GrantDocUserRolesInput) {
@@ -75,7 +92,7 @@ export class DocGrantedUsersStore extends Store {
workspaceId: string,
docId: string,
userId: string,
role: DocRole
role: GrantDocUserRolesInput['role']
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
@@ -1,18 +1,14 @@
import {
type GetDocRolePermissionsQuery,
getDocRolePermissionsQuery,
type GetWorkspaceInfoQuery,
getWorkspaceInfoQuery,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
import type { NbstoreService } from '../../storage';
import type { WorkspaceService } from '../../workspace';
export type WorkspacePermissionActions = keyof Omit<
GetWorkspaceInfoQuery['workspace']['permissions'],
'__typename'
>;
export type WorkspacePermissionActions = string;
export type DocPermissionActions = keyof Omit<
GetDocRolePermissionsQuery['workspace']['doc']['permissions'],
@@ -22,7 +18,8 @@ export type DocPermissionActions = keyof Omit<
export class GuardStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
@@ -30,16 +27,12 @@ export class GuardStore extends Store {
async getWorkspacePermissions(): Promise<
Record<WorkspacePermissionActions, boolean>
> {
if (!this.workspaceServerService.server) {
throw new Error('No server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspaceInfoQuery,
variables: {
workspaceId: this.workspaceService.workspace.id,
},
});
return data.workspace.permissions;
const { access } = await this.nbstoreService.realtime.request(
'workspace.access.get',
{ workspaceId: this.workspaceService.workspace.id },
{ timeoutMs: 10000 }
);
return access.permissions as Record<WorkspacePermissionActions, boolean>;
}
async getDocPermissions(
@@ -1,10 +1,10 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
import type { NbstoreService } from '../../storage';
import { mapWorkspaceMemberSnapshot } from './realtime-mappers';
export class MemberSearchStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(private readonly nbstoreService: NbstoreService) {
super();
}
@@ -15,22 +15,21 @@ export class MemberSearchStore extends Store {
take?: number,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
query,
},
context: {
signal,
},
});
return await this.nbstoreService.realtime
.request(
'workspace.members.get',
{ workspaceId, skip, take, query },
{ signal, timeoutMs: 10000 }
)
.then(data => ({
...data,
members: data.members.map(mapWorkspaceMemberSnapshot),
}));
}
return data.workspace;
subscribeMembers(workspaceId: string) {
return this.nbstoreService.realtime.subscribe('workspace.members.changed', {
workspaceId,
});
}
}
@@ -1,7 +1,6 @@
import {
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
getMembersByWorkspaceIdQuery,
grantWorkspaceTeamMemberMutation,
inviteByEmailsMutation,
type Permission,
@@ -12,9 +11,14 @@ import {
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
import type { NbstoreService } from '../../storage';
import { mapWorkspaceMemberSnapshot } from './realtime-mappers';
export class WorkspaceMembersStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
@@ -24,22 +28,22 @@ export class WorkspaceMembersStore extends Store {
take: number,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
},
context: {
signal,
},
});
return await this.nbstoreService.realtime
.request(
'workspace.members.get',
{ workspaceId, skip, take },
{ signal, timeoutMs: 10000 }
)
.then(data => ({
...data,
members: data.members.map(mapWorkspaceMemberSnapshot),
}));
}
return data.workspace;
subscribeMembers(workspaceId: string) {
return this.nbstoreService.realtime.subscribe('workspace.members.changed', {
workspaceId,
});
}
async inviteBatch(workspaceId: string, emails: string[]) {
@@ -1,30 +1,32 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { getWorkspaceInfoQuery, leaveWorkspaceMutation } from '@affine/graphql';
import { leaveWorkspaceMutation } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import type { WorkspaceLocalState } from '../../workspace';
export class WorkspacePermissionStore extends Store {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly workspaceLocalState: WorkspaceLocalState
private readonly workspaceLocalState: WorkspaceLocalState,
private readonly nbstoreService: NbstoreService
) {
super();
}
async fetchWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const info = await this.workspaceServerService.server.gql({
query: getWorkspaceInfoQuery,
variables: {
workspaceId,
},
context: { signal },
});
const { access } = await this.nbstoreService.realtime.request(
'workspace.access.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return { workspace: access };
}
return info;
subscribeWorkspaceAccess(workspaceId: string) {
return this.nbstoreService.realtime.subscribe('workspace.access.changed', {
workspaceId,
});
}
/**
@@ -0,0 +1,24 @@
import { DocRole, Permission, WorkspaceMemberStatus } from '@affine/graphql';
import type {
DocGrantedUserSnapshot,
WorkspaceMemberSnapshot,
} from '@affine/realtime';
import { mapRealtimeEnum } from '../../cloud/realtime/enum';
export function mapWorkspaceMemberSnapshot(member: WorkspaceMemberSnapshot) {
return {
...member,
permission: mapRealtimeEnum(Permission, member.permission, 'permission'),
role: mapRealtimeEnum(Permission, member.role, 'permission'),
status: mapRealtimeEnum(
WorkspaceMemberStatus,
member.status,
'workspace member status'
),
};
}
export function mapDocGrantedUserSnapshot(node: DocGrantedUserSnapshot) {
return { ...node, role: mapRealtimeEnum(DocRole, node.role, 'doc role') };
}
@@ -1,5 +1,4 @@
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { WorkspaceQuotaQuery } from '@affine/graphql';
import type { WorkspaceQuotaStateSnapshot } from '@affine/realtime';
import { Framework } from '@toeverything/infra';
import { Subject } from 'rxjs';
@@ -8,8 +7,6 @@ import { describe, expect, test, vi } from 'vitest';
import { WorkspaceQuotaStore } from '../stores/quota';
import { WorkspaceQuota } from './quota';
type Quota = WorkspaceQuotaQuery['workspace']['quota'];
const workspaceService = {
workspace: { id: 'workspace-1' },
} as unknown as WorkspaceService;
@@ -43,29 +40,6 @@ function createQuotaState(
};
}
function createQuota(overrides: Partial<Quota> = {}): Quota {
return {
name: 'Legacy',
blobLimit: 1024,
storageQuota: 2048,
usedStorageQuota: 256,
historyPeriod: 30 * 24 * 60 * 60 * 1000,
memberLimit: 8,
memberCount: 2,
overcapacityMemberCount: 0,
humanReadable: {
name: 'Legacy',
blobLimit: '1 KB',
storageQuota: '2 KB',
historyPeriod: '30 days',
memberLimit: '8',
memberCount: '2',
overcapacityMemberCount: '0',
},
...overrides,
};
}
function createStore(
overrides: Partial<WorkspaceQuotaStore>,
eventSubject = new Subject<{ type: 'ready' } | { changed: true }>()
@@ -73,7 +47,6 @@ function createStore(
return {
fetchWorkspaceQuotaState: vi.fn(),
subscribeWorkspaceQuotaState: vi.fn(() => eventSubject),
fetchWorkspaceQuota: vi.fn(),
...overrides,
} as unknown as WorkspaceQuotaStore;
}
@@ -110,21 +83,20 @@ describe('WorkspaceQuota', () => {
await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(4));
expect(store.fetchWorkspaceQuotaState).toHaveBeenCalledTimes(2);
expect(store.fetchWorkspaceQuota).not.toHaveBeenCalled();
quota.dispose();
});
test('falls back to legacy GraphQL quota when realtime request fails', async () => {
test('surfaces realtime quota errors without GraphQL fallback', async () => {
const error = new Error('offline');
const store = createStore({
fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
fetchWorkspaceQuota: vi.fn().mockResolvedValue(createQuota()),
fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(error),
});
const quota = createEntity(store);
quota.revalidate();
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
expect(quota.error$.value).toBeNull();
await vi.waitFor(() => expect(quota.error$.value).toBe(error));
expect(quota.quota$.value).toBeNull();
quota.dispose();
});
});
@@ -1,5 +1,4 @@
import { DebugLogger } from '@affine/debug';
import type { WorkspaceQuotaQuery } from '@affine/graphql';
import type {
RealtimeTopicEventOf,
WorkspaceQuotaStateSnapshot,
@@ -12,7 +11,25 @@ import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
import type { WorkspaceService } from '../../workspace';
import type { WorkspaceQuotaStore } from '../stores/quota';
type QuotaType = WorkspaceQuotaQuery['workspace']['quota'];
type QuotaType = {
name: string;
blobLimit: number;
storageQuota: number;
usedStorageQuota: number;
historyPeriod: number;
memberLimit: number;
memberCount: number;
overcapacityMemberCount: number;
humanReadable: {
name: string;
blobLimit: string;
storageQuota: string;
historyPeriod: string;
memberLimit: string;
memberCount: string;
overcapacityMemberCount: string;
};
};
const logger = new DebugLogger('affine:workspace-permission');
const DAY_SECONDS = 24 * 60 * 60;
@@ -172,11 +189,6 @@ export class WorkspaceQuota extends Entity {
signal
)
);
} catch {
return await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
} finally {
this.isRevalidating$.setValue(false);
}
@@ -3,7 +3,6 @@ export { QuotaCheck } from './views/quota-check';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { NbstoreService } from '../storage';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { WorkspaceQuota } from './entities/quota';
@@ -14,6 +13,6 @@ export function configureQuotaModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceQuotaService)
.store(WorkspaceQuotaStore, [WorkspaceServerService, NbstoreService])
.store(WorkspaceQuotaStore, [NbstoreService])
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
}
@@ -1,14 +1,9 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import { workspaceQuotaQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
export class WorkspaceQuotaStore extends Store {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
constructor(private readonly nbstoreService: NbstoreService) {
super();
}
@@ -27,20 +22,4 @@ export class WorkspaceQuotaStore extends Store {
{ workspaceId }
);
}
async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: workspaceQuotaQuery,
variables: {
id: workspaceId,
},
context: {
signal,
},
});
return data.workspace.quota;
}
}
@@ -1,22 +1,19 @@
import type { GetWorkspacePageByIdQuery, PublicDocMode } from '@affine/graphql';
import {
catchErrorInto,
effect,
Entity,
fromPromise,
LiveData,
mapInto,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import { switchMap } from 'rxjs';
import type { DocRole, PublicDocMode } from '@affine/graphql';
import type {
DocShareStateSnapshot,
RealtimeTopicEventOf,
} from '@affine/realtime';
import { Entity, LiveData } from '@toeverything/infra';
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace';
import type { ShareStore } from '../stores/share';
type ShareInfoType = GetWorkspacePageByIdQuery['workspace']['doc'];
type ShareInfoType = Omit<DocShareStateSnapshot, 'defaultRole' | 'mode'> & {
defaultRole: DocRole;
mode: PublicDocMode;
};
export class ShareInfo extends Entity {
info$ = new LiveData<ShareInfoType | undefined | null>(null);
@@ -32,25 +29,30 @@ export class ShareInfo extends Entity {
private readonly store: ShareStore
) {
super();
this.liveQuery.start();
}
revalidate = effect(
switchMap(() => {
return fromPromise(signal =>
this.store.getShareInfoByDocId(
this.workspaceService.workspace.id,
this.docService.doc.id,
signal
)
).pipe(
smartRetry(),
mapInto(this.info$),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
})
);
private readonly liveQuery = new RealtimeLiveQuery<
ShareInfoType | undefined,
RealtimeTopicEventOf<'doc.share-state.changed'>
>({
request: signal => this.requestShareInfo(signal),
subscribe: () =>
this.store.subscribeShareState(
this.workspaceService.workspace.id,
this.docService.doc.id
),
applySnapshot: info => {
this.error$.next(null);
this.info$.next(info);
},
applyEvent: () => 'revalidate',
onError: error => this.error$.setValue(error),
});
revalidate = () => {
this.liveQuery.revalidate();
};
waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
@@ -77,4 +79,21 @@ export class ShareInfo extends Entity {
);
await this.waitForRevalidation();
}
override dispose(): void {
this.liveQuery.dispose();
}
private async requestShareInfo(signal: AbortSignal) {
this.isRevalidating$.next(true);
try {
return await this.store.getShareInfoByDocId(
this.workspaceService.workspace.id,
this.docService.doc.id,
signal
);
} finally {
this.isRevalidating$.next(false);
}
}
}
@@ -5,6 +5,7 @@ import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { DocScope, DocService } from '../doc';
import { NbstoreService } from '../storage';
import {
WorkspaceLocalCache,
WorkspaceScope,
@@ -30,5 +31,5 @@ export function configureShareDocsModule(framework: Framework) {
.scope(DocScope)
.service(ShareInfoService)
.entity(ShareInfo, [WorkspaceService, DocService, ShareStore])
.store(ShareStore, [WorkspaceServerService]);
.store(ShareStore, [WorkspaceServerService, NbstoreService]);
}
@@ -1,15 +1,20 @@
import type { PublicDocMode } from '@affine/graphql';
import {
getWorkspacePageByIdQuery,
DocRole,
PublicDocMode,
publishPageMutation,
revokePublicPageMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
import { mapRealtimeEnum } from '../../cloud/realtime/enum';
import type { NbstoreService } from '../../storage';
export class ShareStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
@@ -18,20 +23,28 @@ export class ShareStore extends Store {
docId: string,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspacePageByIdQuery,
variables: {
pageId: docId,
workspaceId,
},
context: {
signal,
},
const { state } = await this.nbstoreService.realtime.request(
'doc.share-state.get',
{ workspaceId, docId },
{ signal, timeoutMs: 10000 }
);
return state
? {
id: docId,
...state,
mode: mapRealtimeEnum(PublicDocMode, state.mode, 'public doc mode'),
defaultRole: mapRealtimeEnum(DocRole, state.defaultRole, 'doc role'),
title: null,
summary: null,
}
: undefined;
}
subscribeShareState(workspaceId: string, docId: string) {
return this.nbstoreService.realtime.subscribe('doc.share-state.changed', {
workspaceId,
docId,
});
return data.workspace.doc ?? undefined;
}
async enableSharePage(
@@ -1,90 +1,91 @@
import { DebugLogger } from '@affine/debug';
import type { GetWorkspaceConfigQuery, InviteLink } from '@affine/graphql';
import {
catchErrorInto,
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import { exhaustMap, tap } from 'rxjs';
import type {
RealtimeTopicEventOf,
WorkspaceConfigSnapshot,
WorkspaceInviteLinkSnapshot,
} from '@affine/realtime';
import { Entity, LiveData } from '@toeverything/infra';
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
import type { WorkspaceService } from '../../workspace';
import type { WorkspaceShareSettingStore } from '../stores/share-setting';
type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi'];
type EnableSharing = GetWorkspaceConfigQuery['workspace']['enableSharing'];
type EnableUrlPreview =
GetWorkspaceConfigQuery['workspace']['enableUrlPreview'];
const logger = new DebugLogger('affine:workspace-permission');
type InviteLink = WorkspaceInviteLinkSnapshot;
export class WorkspaceShareSetting extends Entity {
enableAi$ = new LiveData<EnableAi | null>(null);
enableSharing$ = new LiveData<EnableSharing | null>(null);
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
enableAi$ = new LiveData<boolean | null>(null);
enableSharing$ = new LiveData<boolean | null>(null);
enableUrlPreview$ = new LiveData<boolean | null>(null);
inviteLink$ = new LiveData<InviteLink | null>(null);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
private inviteLinkStarted = false;
private inviteLinkExpireTimer?: ReturnType<typeof setTimeout>;
private readonly configLiveQuery = new RealtimeLiveQuery<
WorkspaceConfigSnapshot,
RealtimeTopicEventOf<'workspace.config.changed'>
>({
request: signal => this.requestWorkspaceConfig(signal),
subscribe: () =>
this.store.subscribeWorkspaceConfig(this.workspaceService.workspace.id),
applySnapshot: value => {
this.error$.next(null);
this.enableAi$.next(value.enableAi);
this.enableSharing$.next(value.enableSharing);
this.enableUrlPreview$.next(value.enableUrlPreview);
},
applyEvent: () => 'revalidate',
onError: error => {
logger.error('Failed to fetch workspace share settings', error);
this.error$.setValue(error);
},
});
private readonly inviteLinkLiveQuery = new RealtimeLiveQuery<
InviteLink | null,
RealtimeTopicEventOf<'workspace.invite-link.changed'>
>({
request: signal =>
this.store.fetchInviteLink(this.workspaceService.workspace.id, signal),
subscribe: () =>
this.store.subscribeInviteLink(this.workspaceService.workspace.id),
applySnapshot: value => {
this.error$.next(null);
this.inviteLink$.next(value);
this.scheduleInviteLinkExpiry(value);
},
applyEvent: () => 'revalidate',
onError: error => {
logger.error('Failed to fetch workspace invite link', error);
this.error$.setValue(error);
this.inviteLinkLiveQuery.stop();
this.inviteLinkStarted = false;
},
});
constructor(
private readonly workspaceService: WorkspaceService,
private readonly store: WorkspaceShareSettingStore
) {
super();
this.revalidate();
this.configLiveQuery.start();
}
revalidate = effect(
exhaustMap(() => {
return fromPromise(signal =>
this.store.fetchWorkspaceConfig(
this.workspaceService.workspace.id,
signal
)
).pipe(
smartRetry(),
tap(value => {
if (value) {
this.enableAi$.next(value.enableAi);
this.enableSharing$.next(value.enableSharing);
this.enableUrlPreview$.next(value.enableUrlPreview);
}
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace share settings', error);
}),
onStart(() => this.isLoading$.setValue(true)),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
revalidate = () => {
this.configLiveQuery.revalidate();
};
revalidateInviteLink = effect(
exhaustMap(() => {
return fromPromise(signal =>
this.store.fetchInviteLink(this.workspaceService.workspace.id, signal)
).pipe(
smartRetry(),
tap(value => {
this.inviteLink$.next(value);
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace invite link', error);
})
);
})
);
revalidateInviteLink = () => {
this.ensureInviteLinkStarted();
this.inviteLinkLiveQuery.revalidate();
};
async waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
await this.isLoading$.waitFor(isLoading => !isLoading, signal);
}
async setEnableUrlPreview(enableUrlPreview: EnableUrlPreview) {
async setEnableUrlPreview(enableUrlPreview: boolean) {
await this.store.updateWorkspaceEnableUrlPreview(
this.workspaceService.workspace.id,
enableUrlPreview
@@ -92,7 +93,7 @@ export class WorkspaceShareSetting extends Entity {
await this.waitForRevalidation();
}
async setEnableSharing(enableSharing: EnableSharing) {
async setEnableSharing(enableSharing: boolean) {
await this.store.updateWorkspaceEnableSharing(
this.workspaceService.workspace.id,
enableSharing
@@ -100,7 +101,7 @@ export class WorkspaceShareSetting extends Entity {
await this.waitForRevalidation();
}
async setEnableAi(enableAi: EnableAi) {
async setEnableAi(enableAi: boolean) {
await this.store.updateWorkspaceEnableAi(
this.workspaceService.workspace.id,
enableAi
@@ -109,7 +110,55 @@ export class WorkspaceShareSetting extends Entity {
}
override dispose(): void {
this.revalidate.unsubscribe();
this.revalidateInviteLink.unsubscribe();
this.configLiveQuery.dispose();
this.inviteLinkLiveQuery.dispose();
this.clearInviteLinkExpireTimer();
}
private ensureInviteLinkStarted() {
if (this.inviteLinkStarted) {
return;
}
this.inviteLinkStarted = true;
this.inviteLinkLiveQuery.start();
}
private scheduleInviteLinkExpiry(inviteLink: InviteLink | null) {
this.clearInviteLinkExpireTimer();
if (!inviteLink) {
return;
}
const expireAt = new Date(inviteLink.expireTime).getTime();
if (!Number.isFinite(expireAt)) {
return;
}
const delay = expireAt - Date.now();
if (delay <= 0) {
this.inviteLink$.next(null);
return;
}
this.inviteLinkExpireTimer = setTimeout(() => {
this.inviteLink$.next(null);
}, delay);
this.inviteLinkExpireTimer.unref?.();
}
private clearInviteLinkExpireTimer() {
if (this.inviteLinkExpireTimer) {
clearTimeout(this.inviteLinkExpireTimer);
this.inviteLinkExpireTimer = undefined;
}
}
private async requestWorkspaceConfig(signal: AbortSignal) {
this.isLoading$.setValue(true);
try {
return await this.store.fetchWorkspaceConfig(
this.workspaceService.workspace.id,
signal
);
} finally {
this.isLoading$.setValue(false);
}
}
}
@@ -3,6 +3,7 @@ export { WorkspaceShareSettingService } from './services/share-setting';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { NbstoreService } from '../storage';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { WorkspaceShareSetting } from './entities/share-setting';
import { WorkspaceShareSettingService } from './services/share-setting';
@@ -12,7 +13,7 @@ export function configureShareSettingModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceShareSettingService)
.store(WorkspaceShareSettingStore, [WorkspaceServerService])
.store(WorkspaceShareSettingStore, [WorkspaceServerService, NbstoreService])
.entity(WorkspaceShareSetting, [
WorkspaceService,
WorkspaceShareSettingStore,
@@ -1,48 +1,50 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
getWorkspaceConfigQuery,
getWorkspaceInviteLinkQuery,
setEnableAiMutation,
setEnableSharingMutation,
setEnableUrlPreviewMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
export class WorkspaceShareSettingStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
constructor(
private readonly workspaceServerService: WorkspaceServerService,
private readonly nbstoreService: NbstoreService
) {
super();
}
async fetchWorkspaceConfig(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspaceConfigQuery,
variables: {
id: workspaceId,
},
context: {
signal,
},
const { config } = await this.nbstoreService.realtime.request(
'workspace.config.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return config;
}
subscribeWorkspaceConfig(workspaceId: string) {
return this.nbstoreService.realtime.subscribe('workspace.config.changed', {
workspaceId,
});
return data.workspace;
}
async fetchInviteLink(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspaceInviteLinkQuery,
variables: {
id: workspaceId,
},
context: {
signal,
},
});
return data.workspace.inviteLink;
const { inviteLink } = await this.nbstoreService.realtime.request(
'workspace.invite-link.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return inviteLink;
}
subscribeInviteLink(workspaceId: string) {
return this.nbstoreService.realtime.subscribe(
'workspace.invite-link.changed',
{ workspaceId }
);
}
async updateWorkspaceEnableAi(
@@ -3,7 +3,6 @@ import { DebugLogger } from '@affine/debug';
import {
createWorkspaceMutation,
deleteWorkspaceMutation,
getWorkspaceInfoQuery,
getWorkspacesQuery,
ServerDeploymentType,
ServerFeature,
@@ -67,7 +66,7 @@ import {
GraphQLService,
WorkspaceServerService,
} from '../../cloud';
import type { GlobalState } from '../../storage';
import { type GlobalState, NbstoreService } from '../../storage';
import type {
Workspace,
WorkspaceFlavourProvider,
@@ -90,6 +89,7 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider');
class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
private readonly authService: AuthService;
private readonly graphqlService: GraphQLService;
private readonly nbstoreService: NbstoreService;
private readonly unsubscribeAccountChanged: () => void;
constructor(
@@ -98,6 +98,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
) {
this.authService = server.scope.get(AuthService);
this.graphqlService = server.scope.get(GraphQLService);
this.nbstoreService = server.scope.get(NbstoreService);
this.unsubscribeAccountChanged = this.server.scope.eventBus.on(
AccountChanged,
() => {
@@ -444,13 +445,12 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}
private async getWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
return await this.graphqlService.gql({
query: getWorkspaceInfoQuery,
variables: {
workspaceId,
},
context: { signal },
});
const { access } = await this.nbstoreService.realtime.request(
'workspace.access.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return { workspace: access };
}
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
@@ -4,7 +4,6 @@ import {
addWorkspaceEmbeddingFilesMutation,
addWorkspaceEmbeddingIgnoredDocsMutation,
getAllWorkspaceEmbeddingIgnoredDocsQuery,
getWorkspaceConfigQuery,
getWorkspaceEmbeddingFilesQuery,
type PaginationInput,
removeWorkspaceEmbeddingFilesMutation,
@@ -22,19 +21,12 @@ export class EmbeddingStore extends Store {
}
async getEnabled(workspaceId: string, signal?: AbortSignal) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getWorkspaceConfigQuery,
variables: {
id: workspaceId,
},
context: {
signal,
},
});
return data.workspace.enableDocEmbedding;
const { config } = await this.nbstoreService.realtime.request(
'workspace.config.get',
{ workspaceId },
{ signal, timeoutMs: 10000 }
);
return config.enableDocEmbedding;
}
async updateEnabled(