mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 20:10:24 +08:00
feat(server): entitlement based model (#14996)
#### PR Dependency Tree * **PR #14996** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin mutations to grant/revoke commercial entitlements. * New Doc comment-update permission. * Realtime user/workspace quota-state endpoints and live-update rooms. * **Bug Fixes** * More accurate readable-doc filtering and permission evaluation. * **Refactor** * Workspace feature management moved to entitlement-based model; permission and quota pipelines redesigned. * Admin workspace UI now edits flags only (feature toggles removed). * **Tests** * Extensive new and updated tests for permissions, entitlements, quota, projection, and backfills. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -171,6 +171,18 @@
|
||||
"desc": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated."
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"readModel": {
|
||||
"type": "String",
|
||||
"desc": "Permission data source for Rust evaluation",
|
||||
"env": "AFFINE_PERMISSION_READ_MODEL"
|
||||
},
|
||||
"fallbackLegacyLoader": {
|
||||
"type": "Boolean",
|
||||
"desc": "Fallback from projection loader to legacy loader when projection input loading fails",
|
||||
"env": "AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER"
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"avatar.publicPath": {
|
||||
"type": "String",
|
||||
|
||||
@@ -150,7 +150,6 @@ export const KNOWN_CONFIG_GROUPS = [
|
||||
module: 'copilot',
|
||||
fields: [
|
||||
'enabled',
|
||||
'scenarios',
|
||||
'providers.openai',
|
||||
'providers.gemini',
|
||||
'providers.anthropic',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import {
|
||||
adminUpdateWorkspaceMutation,
|
||||
adminWorkspaceQuery,
|
||||
@@ -17,10 +16,8 @@ import { AccountIcon } from '@blocksuite/icons/rc';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
|
||||
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
|
||||
import { useQuery } from '../../../use-query';
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { WorkspaceDetail } from '../schema';
|
||||
@@ -69,7 +66,6 @@ function WorkspacePanelContent({
|
||||
workspace: WorkspaceDetail;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const serverConfig = useServerConfig();
|
||||
const { setHasDirtyChanges } = useRightPanel();
|
||||
const revalidate = useMutateQueryResource();
|
||||
const { trigger: updateWorkspace, isMutating } = useMutation({
|
||||
@@ -78,7 +74,6 @@ function WorkspacePanelContent({
|
||||
|
||||
const normalizedWorkspace = useMemo(
|
||||
() => ({
|
||||
features: [...workspace.features],
|
||||
flags: {
|
||||
public: workspace.public,
|
||||
enableAi: workspace.enableAi,
|
||||
@@ -91,14 +86,10 @@ function WorkspacePanelContent({
|
||||
[workspace]
|
||||
);
|
||||
|
||||
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
|
||||
normalizedWorkspace.features
|
||||
);
|
||||
const [flags, setFlags] = useState(normalizedWorkspace.flags);
|
||||
const [baseline, setBaseline] = useState(normalizedWorkspace);
|
||||
|
||||
useEffect(() => {
|
||||
setFeatureSelection(normalizedWorkspace.features);
|
||||
setFlags(normalizedWorkspace.flags);
|
||||
setBaseline(normalizedWorkspace);
|
||||
}, [normalizedWorkspace]);
|
||||
@@ -110,20 +101,14 @@ function WorkspacePanelContent({
|
||||
flags.enableSharing !== baseline.flags.enableSharing ||
|
||||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
|
||||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
|
||||
flags.name !== baseline.flags.name ||
|
||||
featureSelection.length !== baseline.features.length ||
|
||||
featureSelection.some(f => !baseline.features.includes(f))
|
||||
flags.name !== baseline.flags.name
|
||||
);
|
||||
}, [baseline, featureSelection, flags]);
|
||||
}, [baseline, flags]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasDirtyChanges(hasChanges);
|
||||
}, [hasChanges, setHasDirtyChanges]);
|
||||
|
||||
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
|
||||
setFeatureSelection(features);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
@@ -136,7 +121,6 @@ function WorkspacePanelContent({
|
||||
enableUrlPreview: flags.enableUrlPreview,
|
||||
enableDocEmbedding: flags.enableDocEmbedding,
|
||||
name: flags.name || null,
|
||||
features: featureSelection,
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
@@ -146,7 +130,6 @@ function WorkspacePanelContent({
|
||||
toast.success('Workspace updated successfully');
|
||||
setBaseline({
|
||||
flags: { ...flags },
|
||||
features: [...featureSelection],
|
||||
});
|
||||
setHasDirtyChanges(false);
|
||||
onClose();
|
||||
@@ -156,7 +139,6 @@ function WorkspacePanelContent({
|
||||
};
|
||||
update().catch(() => {});
|
||||
}, [
|
||||
featureSelection,
|
||||
flags,
|
||||
onClose,
|
||||
revalidate,
|
||||
@@ -239,18 +221,6 @@ function WorkspacePanelContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/60 bg-card p-3 shadow-sm">
|
||||
<div className="text-sm font-medium">Features</div>
|
||||
<FeatureToggleList
|
||||
features={serverConfig.availableWorkspaceFeatures ?? []}
|
||||
selected={featureSelection}
|
||||
onChange={handleFeaturesChange}
|
||||
className="grid grid-cols-1 gap-2"
|
||||
control="checkbox"
|
||||
controlPosition="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MetricCard
|
||||
label="Snapshot Size"
|
||||
|
||||
-7
@@ -16,7 +16,6 @@ public struct AdminUpdateWorkspaceInput: InputObject {
|
||||
enableDocEmbedding: GraphQLNullable<Bool> = nil,
|
||||
enableSharing: GraphQLNullable<Bool> = nil,
|
||||
enableUrlPreview: GraphQLNullable<Bool> = nil,
|
||||
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
|
||||
id: String,
|
||||
name: GraphQLNullable<String> = nil,
|
||||
`public`: GraphQLNullable<Bool> = nil
|
||||
@@ -27,7 +26,6 @@ public struct AdminUpdateWorkspaceInput: InputObject {
|
||||
"enableDocEmbedding": enableDocEmbedding,
|
||||
"enableSharing": enableSharing,
|
||||
"enableUrlPreview": enableUrlPreview,
|
||||
"features": features,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"public": `public`
|
||||
@@ -59,11 +57,6 @@ public struct AdminUpdateWorkspaceInput: InputObject {
|
||||
set { __data["enableUrlPreview"] = newValue }
|
||||
}
|
||||
|
||||
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
|
||||
get { __data["features"] }
|
||||
set { __data["features"] = newValue }
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
get { __data["id"] }
|
||||
set { __data["id"] = newValue }
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
|
||||
import type { UserQuotaStateSnapshot } from '@affine/realtime';
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
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$']: {
|
||||
value: { id: 'user-1' },
|
||||
},
|
||||
},
|
||||
} as unknown as AuthService;
|
||||
|
||||
function createQuotaState(
|
||||
overrides: Partial<UserQuotaStateSnapshot> = {}
|
||||
): UserQuotaStateSnapshot {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
plan: 'pro',
|
||||
sourceEntitlementId: 'entitlement-1',
|
||||
blobLimit: 1024,
|
||||
storageQuota: 2048,
|
||||
usedStorageQuota: 512,
|
||||
historyPeriodSeconds: 30 * 24 * 60 * 60,
|
||||
copilotActionLimit: null,
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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 }>()
|
||||
) {
|
||||
return {
|
||||
fetchUserQuotaState: vi.fn(),
|
||||
subscribeUserQuotaState: vi.fn(() => eventSubject),
|
||||
fetchUserQuota: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as UserQuotaStore;
|
||||
}
|
||||
|
||||
function createEntity(store: UserQuotaStore) {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.service(AuthService, authService)
|
||||
.store(UserQuotaStore, store)
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore]);
|
||||
|
||||
return framework.provider().createEntity(UserQuota);
|
||||
}
|
||||
|
||||
describe('UserQuota', () => {
|
||||
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
|
||||
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
|
||||
const store = createStore(
|
||||
{
|
||||
fetchUserQuotaState: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 512 }))
|
||||
.mockResolvedValueOnce(createQuotaState({ usedStorageQuota: 768 })),
|
||||
},
|
||||
events$
|
||||
);
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
await vi.waitFor(() => expect(quota.used$.value).toBe(512));
|
||||
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
|
||||
|
||||
events$.next({ changed: true });
|
||||
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 () => {
|
||||
const store = createStore({
|
||||
fetchUserQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
|
||||
fetchUserQuota: vi.fn().mockResolvedValue({
|
||||
quota: createQuota(),
|
||||
used: 256,
|
||||
}),
|
||||
});
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
|
||||
expect(quota.used$.value).toBe(256);
|
||||
quota.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,74 @@
|
||||
import type { QuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapSwitchUntilChanged,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import type { GetCurrentUserProfileQuery } from '@affine/graphql';
|
||||
import type {
|
||||
RealtimeTopicEventOf,
|
||||
UserQuotaStateSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import bytes from 'bytes';
|
||||
import { map, tap } from 'rxjs';
|
||||
|
||||
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'];
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function formatSize(size: number) {
|
||||
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
|
||||
}
|
||||
|
||||
function formatHistoryPeriod(value: number) {
|
||||
return `${(value / DAY_SECONDS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
function userMemberLimit(plan: string) {
|
||||
return plan === 'pro' || plan === 'lifetime_pro' || plan === 'selfhost_free'
|
||||
? 10
|
||||
: 3;
|
||||
}
|
||||
|
||||
function planName(plan: string) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
case 'selfhost_free':
|
||||
return 'Pro';
|
||||
case 'lifetime_pro':
|
||||
return 'Lifetime Pro';
|
||||
case 'ai':
|
||||
return 'AI';
|
||||
default:
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
|
||||
function userQuotaFromState(state: UserQuotaStateSnapshot): QuotaType {
|
||||
const name = planName(state.plan);
|
||||
const memberLimit = userMemberLimit(state.plan);
|
||||
return {
|
||||
name,
|
||||
blobLimit: state.blobLimit,
|
||||
storageQuota: state.storageQuota,
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit,
|
||||
humanReadable: {
|
||||
name,
|
||||
blobLimit: formatSize(state.blobLimit),
|
||||
storageQuota: formatSize(state.storageQuota),
|
||||
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
|
||||
memberLimit: memberLimit.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class UserQuota extends Entity {
|
||||
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota'] | null>(
|
||||
null
|
||||
);
|
||||
quota$ = new LiveData<QuotaType | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
/** Formatted used storage */
|
||||
@@ -53,8 +101,17 @@ export class UserQuota extends Entity {
|
||||
: null
|
||||
);
|
||||
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any | null>(null);
|
||||
private started = false;
|
||||
private readonly liveQuery = new RealtimeLiveQuery<
|
||||
{ quota: QuotaType; used: number },
|
||||
RealtimeTopicEventOf<'user.quota-state.changed'>
|
||||
>({
|
||||
request: signal => this.requestQuota(signal),
|
||||
subscribe: () => this.store.subscribeUserQuotaState(),
|
||||
applySnapshot: data => this.applyQuota(data.quota, data.used),
|
||||
applyEvent: () => 'revalidate',
|
||||
onError: error => this.error$.next(error),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@@ -63,42 +120,19 @@ export class UserQuota extends Entity {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
map(() => ({
|
||||
accountId: this.authService.session.account$.value?.id,
|
||||
})),
|
||||
exhaustMapSwitchUntilChanged(
|
||||
(a, b) => a.accountId === b.accountId,
|
||||
({ accountId }) =>
|
||||
fromPromise(async signal => {
|
||||
if (!accountId) {
|
||||
return; // no quota if no user
|
||||
}
|
||||
const { quota, used } = await this.store.fetchUserQuota(signal);
|
||||
|
||||
return { quota, used };
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(data => {
|
||||
if (data) {
|
||||
const { quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
}
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isRevalidating$.next(true)),
|
||||
onComplete(() => this.isRevalidating$.next(false))
|
||||
),
|
||||
() => {
|
||||
// Reset the state when the user is changed
|
||||
this.reset();
|
||||
}
|
||||
)
|
||||
);
|
||||
revalidate = () => {
|
||||
if (!this.authService.session.account$.value?.id) {
|
||||
this.liveQuery.stop();
|
||||
this.started = false;
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
this.liveQuery.start();
|
||||
}
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
reset() {
|
||||
this.quota$.next(null);
|
||||
@@ -108,6 +142,28 @@ export class UserQuota extends Entity {
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private applyQuota(quota: QuotaType | null, used: number | null) {
|
||||
this.error$.next(null);
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
}
|
||||
|
||||
private async requestQuota(signal: AbortSignal) {
|
||||
this.isRevalidating$.setValue(true);
|
||||
try {
|
||||
const state = await this.store.fetchUserQuotaState(signal);
|
||||
return {
|
||||
quota: userQuotaFromState(state),
|
||||
used: state.usedStorageQuota,
|
||||
};
|
||||
} catch {
|
||||
const { quota, used } = await this.store.fetchUserQuota(signal);
|
||||
return { quota, used };
|
||||
} finally {
|
||||
this.isRevalidating$.setValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function configureCloudModule(framework: Framework) {
|
||||
.entity(Subscription, [AuthService, ServerService, SubscriptionStore])
|
||||
.entity(SubscriptionPrices, [ServerService, SubscriptionStore])
|
||||
.service(UserQuotaService)
|
||||
.store(UserQuotaStore, [GraphQLService])
|
||||
.store(UserQuotaStore, [GraphQLService, NbstoreService])
|
||||
.entity(UserQuota, [AuthService, UserQuotaStore])
|
||||
.service(UserCopilotQuotaService)
|
||||
.store(UserCopilotQuotaStore, [GraphQLService])
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
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) {
|
||||
constructor(
|
||||
private readonly graphqlService: GraphQLService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchUserQuotaState(abortSignal?: AbortSignal) {
|
||||
const response = await this.nbstoreService.realtime.request(
|
||||
'user.quota-state.get',
|
||||
{},
|
||||
{ signal: abortSignal, timeoutMs: 10000 }
|
||||
);
|
||||
return response.state;
|
||||
}
|
||||
|
||||
subscribeUserQuotaState() {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'user.quota-state.changed',
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async fetchUserQuota(abortSignal?: AbortSignal) {
|
||||
const data = await this.graphqlService.gql({
|
||||
query: getCurrentUserProfileQuery,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Permission } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
effect,
|
||||
@@ -46,9 +45,11 @@ export class WorkspacePermission extends Entity {
|
||||
signal
|
||||
);
|
||||
|
||||
const isOwner = info.workspace.permissions.Workspace_Delete;
|
||||
return {
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isOwner,
|
||||
isAdmin:
|
||||
!isOwner && info.workspace.permissions.Workspace_Settings_Update,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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';
|
||||
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;
|
||||
|
||||
function createQuotaState(
|
||||
overrides: Partial<WorkspaceQuotaStateSnapshot> = {}
|
||||
): WorkspaceQuotaStateSnapshot {
|
||||
return {
|
||||
workspaceId: 'workspace-1',
|
||||
plan: 'Team',
|
||||
sourceEntitlementId: 'entitlement-1',
|
||||
ownerUserId: 'user-1',
|
||||
usesOwnerQuota: false,
|
||||
seatLimit: 10,
|
||||
memberCount: 3,
|
||||
overcapacityMemberCount: 0,
|
||||
blobLimit: 1024,
|
||||
storageQuota: 2048,
|
||||
usedStorageQuota: 512,
|
||||
historyPeriodSeconds: 30 * 24 * 60 * 60,
|
||||
readonly: false,
|
||||
readonlyReasons: [],
|
||||
flags: {},
|
||||
known: true,
|
||||
stale: false,
|
||||
lastReconciledAt: null,
|
||||
staleAfter: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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 }>()
|
||||
) {
|
||||
return {
|
||||
fetchWorkspaceQuotaState: vi.fn(),
|
||||
subscribeWorkspaceQuotaState: vi.fn(() => eventSubject),
|
||||
fetchWorkspaceQuota: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as WorkspaceQuotaStore;
|
||||
}
|
||||
|
||||
function createEntity(store: WorkspaceQuotaStore) {
|
||||
const framework = new Framework();
|
||||
framework
|
||||
.service(WorkspaceService, workspaceService)
|
||||
.store(WorkspaceQuotaStore, store)
|
||||
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
|
||||
|
||||
return framework.provider().createEntity(WorkspaceQuota);
|
||||
}
|
||||
|
||||
describe('WorkspaceQuota', () => {
|
||||
test('uses realtime quota state snapshots and refreshes on quota events', async () => {
|
||||
const events$ = new Subject<{ type: 'ready' } | { changed: true }>();
|
||||
const store = createStore(
|
||||
{
|
||||
fetchWorkspaceQuotaState: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createQuotaState({ memberCount: 3 }))
|
||||
.mockResolvedValueOnce(createQuotaState({ memberCount: 4 })),
|
||||
},
|
||||
events$
|
||||
);
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.memberCount).toBe(3));
|
||||
expect(quota.quota$.value?.humanReadable.historyPeriod).toBe('30 days');
|
||||
|
||||
events$.next({ changed: true });
|
||||
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 () => {
|
||||
const store = createStore({
|
||||
fetchWorkspaceQuotaState: vi.fn().mockRejectedValue(new Error('offline')),
|
||||
fetchWorkspaceQuota: vi.fn().mockResolvedValue(createQuota()),
|
||||
});
|
||||
const quota = createEntity(store);
|
||||
|
||||
quota.revalidate();
|
||||
|
||||
await vi.waitFor(() => expect(quota.quota$.value?.name).toBe('Legacy'));
|
||||
expect(quota.error$.value).toBeNull();
|
||||
quota.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,90 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { WorkspaceQuotaQuery } from '@affine/graphql';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import type {
|
||||
RealtimeTopicEventOf,
|
||||
WorkspaceQuotaStateSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import bytes from 'bytes';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { RealtimeLiveQuery } from '../../cloud/realtime/live-query';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { WorkspaceQuotaStore } from '../stores/quota';
|
||||
|
||||
type QuotaType = WorkspaceQuotaQuery['workspace']['quota'];
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function formatSize(size: number) {
|
||||
return size === 0 ? '0 B' : (bytes.format(size) ?? '0 B');
|
||||
}
|
||||
|
||||
function formatHistoryPeriod(value: number) {
|
||||
return `${(value / DAY_SECONDS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
function planName(plan: string) {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
case 'selfhost_free':
|
||||
return 'Pro';
|
||||
case 'lifetime_pro':
|
||||
return 'Lifetime Pro';
|
||||
case 'ai':
|
||||
return 'AI';
|
||||
case 'team':
|
||||
case 'selfhost_team':
|
||||
return 'Team';
|
||||
default:
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceQuotaFromState(
|
||||
state: WorkspaceQuotaStateSnapshot
|
||||
): QuotaType {
|
||||
const name = planName(state.plan);
|
||||
return {
|
||||
name,
|
||||
blobLimit: state.blobLimit,
|
||||
storageQuota: state.storageQuota,
|
||||
usedStorageQuota: state.usedStorageQuota,
|
||||
historyPeriod: state.historyPeriodSeconds,
|
||||
memberLimit: state.seatLimit,
|
||||
memberCount: state.memberCount,
|
||||
overcapacityMemberCount: state.overcapacityMemberCount,
|
||||
humanReadable: {
|
||||
name,
|
||||
blobLimit: formatSize(state.blobLimit),
|
||||
storageQuota: formatSize(state.storageQuota),
|
||||
historyPeriod: formatHistoryPeriod(state.historyPeriodSeconds),
|
||||
memberLimit: state.seatLimit.toString(),
|
||||
memberCount: state.memberCount.toString(),
|
||||
overcapacityMemberCount: state.overcapacityMemberCount.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkspaceQuota extends Entity {
|
||||
quota$ = new LiveData<QuotaType | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
private started = false;
|
||||
private readonly liveQuery = new RealtimeLiveQuery<
|
||||
QuotaType,
|
||||
RealtimeTopicEventOf<'workspace.quota-state.changed'>
|
||||
>({
|
||||
request: signal => this.requestQuota(signal),
|
||||
subscribe: () =>
|
||||
this.store.subscribeWorkspaceQuotaState(
|
||||
this.workspaceService.workspace.id
|
||||
),
|
||||
applySnapshot: quota => this.applyQuota(quota),
|
||||
applyEvent: () => 'revalidate',
|
||||
onError: error => this.handleError(error),
|
||||
});
|
||||
|
||||
/** Used storage in bytes */
|
||||
used$ = new LiveData<number | null>(null);
|
||||
@@ -66,34 +125,13 @@ export class WorkspaceQuota extends Entity {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMapWithTrailing(() => {
|
||||
return fromPromise(async signal => {
|
||||
const data = await this.store.fetchWorkspaceQuota(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
return { quota: data, used: data.usedStorageQuota };
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(data => {
|
||||
if (data) {
|
||||
const { quota, used } = data;
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(used);
|
||||
} else {
|
||||
this.quota$.next(null);
|
||||
this.used$.next(null);
|
||||
}
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch workspace quota', error);
|
||||
}),
|
||||
onStart(() => this.isRevalidating$.setValue(true)),
|
||||
onComplete(() => this.isRevalidating$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
revalidate = () => {
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
this.liveQuery.start();
|
||||
}
|
||||
this.liveQuery.revalidate();
|
||||
};
|
||||
|
||||
waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
@@ -111,6 +149,36 @@ export class WorkspaceQuota extends Entity {
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
this.liveQuery.dispose();
|
||||
}
|
||||
|
||||
private applyQuota(quota: QuotaType | null) {
|
||||
this.error$.next(null);
|
||||
this.quota$.next(quota);
|
||||
this.used$.next(quota?.usedStorageQuota ?? null);
|
||||
}
|
||||
|
||||
private handleError(error: unknown) {
|
||||
logger.error('Failed to fetch workspace quota', error);
|
||||
this.error$.next(error);
|
||||
}
|
||||
|
||||
private async requestQuota(signal: AbortSignal) {
|
||||
this.isRevalidating$.setValue(true);
|
||||
try {
|
||||
return workspaceQuotaFromState(
|
||||
await this.store.fetchWorkspaceQuotaState(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return await this.store.fetchWorkspaceQuota(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
this.isRevalidating$.setValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { WorkspaceQuotaService } from './services/quota';
|
||||
@@ -13,6 +14,6 @@ export function configureQuotaModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceQuotaService)
|
||||
.store(WorkspaceQuotaStore, [WorkspaceServerService])
|
||||
.store(WorkspaceQuotaStore, [WorkspaceServerService, NbstoreService])
|
||||
.entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,32 @@ 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) {
|
||||
constructor(
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchWorkspaceQuotaState(workspaceId: string, signal?: AbortSignal) {
|
||||
const response = await this.nbstoreService.realtime.request(
|
||||
'workspace.quota-state.get',
|
||||
{ workspaceId },
|
||||
{ signal, timeoutMs: 10000 }
|
||||
);
|
||||
return response.state;
|
||||
}
|
||||
|
||||
subscribeWorkspaceQuotaState(workspaceId: string) {
|
||||
return this.nbstoreService.realtime.subscribe(
|
||||
'workspace.quota-state.changed',
|
||||
{ workspaceId }
|
||||
);
|
||||
}
|
||||
|
||||
async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
deleteWorkspaceMutation,
|
||||
getWorkspaceInfoQuery,
|
||||
getWorkspacesQuery,
|
||||
Permission,
|
||||
ServerDeploymentType,
|
||||
ServerFeature,
|
||||
} from '@affine/graphql';
|
||||
@@ -355,13 +354,12 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
|
||||
const info = await this.getWorkspaceInfo(id, signal);
|
||||
|
||||
const isOwner = info.workspace.permissions.Workspace_Delete;
|
||||
const isAdmin =
|
||||
!isOwner && info.workspace.permissions.Workspace_Settings_Update;
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return {
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isTeam: info.workspace.team,
|
||||
isEmpty,
|
||||
};
|
||||
return { isOwner, isAdmin, isTeam: info.workspace.team, isEmpty };
|
||||
}
|
||||
|
||||
const client = getWorkspaceProfileWorker();
|
||||
@@ -374,8 +372,8 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
return {
|
||||
name: result.name,
|
||||
avatar: result.avatar,
|
||||
isOwner: info.workspace.role === Permission.Owner,
|
||||
isAdmin: info.workspace.role === Permission.Admin,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
isEmpty,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user