feat: integrate user usage into apis (#5075)

This commit is contained in:
DarkSky 2023-12-14 09:50:36 +00:00
parent 63de73a815
commit ad23ead5e4
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
36 changed files with 984 additions and 282 deletions

View File

@ -374,7 +374,9 @@ jobs:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
run: |
yarn workspace @affine/server data-migration run
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@ -464,7 +466,9 @@ jobs:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
run: |
yarn workspace @affine/server data-migration run
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
- name: Download storage.node
uses: actions/download-artifact@v3
with:

View File

@ -135,7 +135,8 @@
"ENABLE_LOCAL_EMAIL": "true",
"OAUTH_EMAIL_LOGIN": "noreply@toeverything.info",
"OAUTH_EMAIL_PASSWORD": "affine",
"OAUTH_EMAIL_SENDER": "noreply@toeverything.info"
"OAUTH_EMAIL_SENDER": "noreply@toeverything.info",
"FEATURES_EARLY_ACCESS_PREVIEW": "false"
}
},
"nodemonConfig": {

View File

@ -28,6 +28,7 @@ model User {
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
UserQuotaGates UserQuotaGates[]
@@map("users")
}
@ -157,6 +158,33 @@ model Features {
@@map("features")
}
// quota gates is a way to enable/disable quotas for a user
// for example, pro plan is a quota that allow some users access to more resources after they pay
model UserQuotaGates {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
quotaId String? @db.VarChar
reason String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
quota UserQuotas? @relation(fields: [quotaId], references: [id])
@@map("user_quota_gates")
}
model UserQuotas {
id String @id @default(uuid()) @db.VarChar
quota String @db.VarChar
configs Json @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
UserQuotaGates UserQuotaGates[]
@@map("user_quotas")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")

View File

@ -188,11 +188,6 @@ export interface AFFiNEConfig {
fs: {
path: string;
};
/**
* default storage quota
* @default 10 * 1024 * 1024 * 1024 (10GB)
*/
quota: number;
};
/**

View File

@ -58,7 +58,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFINE_ENV: 'affineEnv',
AFFINE_FREE_USER_QUOTA: 'objectStorage.quota',
DATABASE_URL: 'db.url',
ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'],
R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId',
@ -192,8 +191,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
fs: {
path: join(homedir(), '.affine-storage'),
},
// 10GB
quota: 10 * 1024 * 1024 * 1024,
},
rateLimiter: {
ttl: 60,

View File

@ -1,12 +1,39 @@
import {
CommonFeature,
FeatureKind,
Features,
FeatureType,
upsertFeature,
} from '../../modules/features';
import { Quotas } from '../../modules/quota';
} from '../../modules/features/types';
import { Quotas } from '../../modules/quota/types';
import { PrismaService } from '../../prisma';
// upgrade features from lower version to higher version
async function upsertFeature(
db: PrismaService,
feature: CommonFeature
): Promise<void> {
const hasEqualOrGreaterVersion =
(await db.features.count({
where: {
feature: feature.feature,
version: {
gte: feature.version,
},
},
})) > 0;
// will not update exists version
if (!hasEqualOrGreaterVersion) {
await db.features.create({
data: {
feature: feature.feature,
type: feature.type,
version: feature.version,
configs: feature.configs,
},
});
}
}
export class UserFeaturesInit1698652531198 {
// do the migration
static async up(db: PrismaService) {

View File

@ -11,8 +11,8 @@ import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { SessionService } from '../../session';
import { NewFeaturesKind } from '../users/types';
import { isStaff } from '../users/utils';
import { FeatureType } from '../features';
import { Quota_FreePlanV1 } from '../quota';
import { MailService } from './mailer';
import {
decode,
@ -44,6 +44,17 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
email: data.email,
avatarUrl: '',
emailVerified: data.emailVerified,
features: {
create: {
reason: 'created by email sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
},
},
},
},
};
if (data.email && !data.name) {
userData.name = data.email.split('@')[0];
@ -223,18 +234,23 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
}
const email = profile?.email ?? user.email;
if (email) {
if (isStaff(email)) {
return true;
}
return prisma.newFeaturesWaitingList
.findUnique({
// FIXME: cannot inject FeatureManagementService here
// it will cause prisma.account to be undefined
// then prismaAdapter.getUserByAccount will throw error
if (email.endsWith('@toeverything.info')) return true;
return prisma.userFeatures
.count({
where: {
email,
type: NewFeaturesKind.EarlyAccess,
user: {
email,
},
feature: {
feature: FeatureType.EarlyAccess,
},
activated: true,
},
})
.then(user => !!user)
.catch(() => false);
.then(count => count > 0);
}
return false;
},

View File

@ -14,6 +14,7 @@ import { nanoid } from 'nanoid';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { verifyChallengeResponse } from '../../storage';
import { Quota_FreePlanV1 } from '../quota';
import { MailService } from './mailer';
export type UserClaim = Pick<
@ -190,6 +191,17 @@ export class AuthService {
name,
email,
password: hashedPassword,
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
},
},
},
},
},
});
}
@ -209,6 +221,17 @@ export class AuthService {
data: {
name: 'Unnamed',
email,
features: {
create: {
reason: 'created by invite sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
},
},
},
},
},
});
}
@ -258,6 +281,7 @@ export class AuthService {
},
});
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {

View File

@ -3,34 +3,6 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { FeatureService } from './configure';
import { FeatureManagementService } from './feature';
import type { CommonFeature } from './types';
// upgrade features from lower version to higher version
async function upsertFeature(
db: PrismaService,
feature: CommonFeature
): Promise<void> {
const hasEqualOrGreaterVersion =
(await db.features.count({
where: {
feature: feature.feature,
version: {
gte: feature.version,
},
},
})) > 0;
// will not update exists version
if (!hasEqualOrGreaterVersion) {
await db.features.create({
data: {
feature: feature.feature,
type: feature.type,
version: feature.version,
configs: feature.configs,
},
});
}
}
/**
* Feature module provider pre-user feature flag management.
@ -46,9 +18,4 @@ export class FeatureModule {}
export type { CommonFeature, Feature } from './types';
export { FeatureKind, Features, FeatureType } from './types';
export {
FeatureManagementService,
FeatureService,
PrismaService,
upsertFeature,
};
export { FeatureManagementService, FeatureService, PrismaService };

View File

@ -6,6 +6,7 @@ import { GqlModule } from '../graphql.module';
import { ServerConfigModule } from './config';
import { DocModule } from './doc';
import { PaymentModule } from './payment';
import { QuotaModule } from './quota';
import { SelfHostedModule } from './self-hosted';
import { SyncModule } from './sync';
import { UsersModule } from './users';
@ -37,7 +38,8 @@ switch (SERVER_FLAVOR) {
WorkspaceModule,
UsersModule,
DocModule,
PaymentModule
PaymentModule,
QuotaModule
);
break;
case 'allinone':
@ -48,6 +50,7 @@ switch (SERVER_FLAVOR) {
GqlModule,
WorkspaceModule,
UsersModule,
QuotaModule,
SyncModule,
DocModule,
PaymentModule

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { UsersModule } from '../users';
import { FeatureModule } from '../features';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service';
@ -8,7 +8,7 @@ import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';
@Module({
imports: [UsersModule],
imports: [FeatureModule],
providers: [
ScheduleManager,
StripeProvider,

View File

@ -11,7 +11,7 @@ import Stripe from 'stripe';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { UsersService } from '../users';
import { FeatureManagementService } from '../features';
import { ScheduleManager } from './schedule';
const OnEvent = (
@ -82,8 +82,8 @@ export class SubscriptionService {
config: Config,
private readonly stripe: Stripe,
private readonly db: PrismaService,
private readonly user: UsersService,
private readonly scheduleManager: ScheduleManager
private readonly scheduleManager: ScheduleManager,
private readonly features: FeatureManagementService
) {
this.paymentConfig = config.payment;
@ -658,7 +658,7 @@ export class SubscriptionService {
user: User,
couponType: CouponType
): Promise<string | null> {
const earlyAccess = await this.user.isEarlyAccessUser(user.email);
const earlyAccess = await this.features.canEarlyAccess(user.email);
if (earlyAccess) {
try {
const coupon = await this.stripe.coupons.retrieve(couponType);

View File

@ -1,42 +0,0 @@
type FeatureEarlyAccessPreview = {
whitelist: RegExp[];
};
type FeatureStorageLimit = {
storageQuota: number;
};
type UserFeatureGate = {
earlyAccessPreview: FeatureEarlyAccessPreview;
freeUser: FeatureStorageLimit;
proUser: FeatureStorageLimit;
};
const UserLevel = {
freeUser: {
storageQuota: 10 * 1024 * 1024 * 1024,
},
proUser: {
storageQuota: 100 * 1024 * 1024 * 1024,
},
} satisfies Pick<UserFeatureGate, 'freeUser' | 'proUser'>;
export function getStorageQuota(features: string[]) {
for (const feature of features) {
if (feature in UserLevel) {
return UserLevel[feature as keyof typeof UserLevel].storageQuota;
}
}
return null;
}
const UserType = {
earlyAccessPreview: {
whitelist: [/@toeverything\.info$/],
},
} satisfies Pick<UserFeatureGate, 'earlyAccessPreview'>;
export const FeatureGates = {
...UserType,
...UserLevel,
} satisfies UserFeatureGate;

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { StorageModule } from '../storage';
import { UserResolver } from './resolver';
import { UsersService } from './users';
@Module({
imports: [StorageModule],
imports: [StorageModule, FeatureModule],
providers: [UserResolver, UsersService],
exports: [UsersService],
})

View File

@ -12,7 +12,6 @@ import {
Mutation,
ObjectType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
@ -24,14 +23,10 @@ import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features';
import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types';
import { UsersService } from './users';
import { isStaff } from './utils';
registerEnumType(NewFeaturesKind, {
name: 'NewFeaturesKind',
});
@ObjectType()
export class UserType implements Partial<User> {
@ -71,14 +66,6 @@ export class RemoveAvatar {
success!: boolean;
}
@ObjectType()
export class AddToNewFeaturesWaitingList {
@Field()
email!: string;
@Field(() => NewFeaturesKind, { description: 'New features kind' })
type!: NewFeaturesKind;
}
/**
* User resolver
* All op rate limit: 10 req/m
@ -88,9 +75,11 @@ export class AddToNewFeaturesWaitingList {
@Resolver(() => UserType)
export class UserResolver {
constructor(
private readonly auth: AuthService,
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly users: UsersService
private readonly users: UsersService,
private readonly feature: FeatureManagementService
) {}
@Throttle({
@ -138,7 +127,7 @@ export class UserResolver {
})
@Public()
async user(@Args('email') email: string) {
if (!(await this.users.canEarlyAccess(email))) {
if (!(await this.feature.canEarlyAccess(email))) {
return new GraphQLError(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
{
@ -233,27 +222,55 @@ export class UserResolver {
ttl: 60,
},
})
@Mutation(() => AddToNewFeaturesWaitingList)
async addToNewFeaturesWaitingList(
@CurrentUser() user: UserType,
@Args('type', {
type: () => NewFeaturesKind,
})
type: NewFeaturesKind,
@Mutation(() => Int)
async addToEarlyAccess(
@CurrentUser() currentUser: UserType,
@Args('email') email: string
): Promise<AddToNewFeaturesWaitingList> {
if (!isStaff(user.email)) {
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
await this.prisma.newFeaturesWaitingList.create({
data: {
email,
type,
},
});
return {
email,
type,
};
const user = await this.users.findUserByEmail(email);
if (user) {
return this.feature.addEarlyAccess(user.id);
} else {
const user = await this.auth.createAnonymousUser(email);
return this.feature.addEarlyAccess(user.id);
}
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => Int)
async removeEarlyAccess(
@CurrentUser() currentUser: UserType,
@Args('email') email: string
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new BadRequestException(`User ${email} not found`);
}
return this.feature.removeEarlyAccess(user.id);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Query(() => [UserType])
async listEarlyAccess(@CurrentUser() user: UserType): Promise<UserType[]> {
if (!this.feature.isStaff(user.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
return this.feature.listEarlyAccess();
}
}

View File

@ -1,3 +0,0 @@
export enum NewFeaturesKind {
EarlyAccess,
}

View File

@ -1,54 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { getStorageQuota } from './gates';
import { NewFeaturesKind } from './types';
import { isStaff } from './utils';
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly config: Config
) {}
async canEarlyAccess(email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
return this.isEarlyAccessUser(email);
} else {
return true;
}
}
async isEarlyAccessUser(email: string) {
return this.prisma.newFeaturesWaitingList
.count({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.then(count => count > 0)
.catch(() => false);
}
async getStorageQuotaById(id: string) {
const features = await this.prisma.user
.findUnique({
where: { id },
select: {
features: {
select: {
feature: true,
},
},
},
})
.then(user => user?.features.map(f => f.feature) ?? []);
return (
getStorageQuota(features.map(f => f.feature)) ||
this.config.objectStorage.quota
);
}
constructor(private readonly prisma: PrismaService) {}
async findUserByEmail(email: string) {
return this.prisma.user

View File

@ -1,3 +0,0 @@
export function isStaff(email: string) {
return email.endsWith('@toeverything.info');
}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { DocModule } from '../doc';
import { QuotaModule } from '../quota';
import { UsersService } from '../users';
import { WorkspacesController } from './controller';
import { DocHistoryResolver } from './history.resolver';
@ -8,7 +9,7 @@ import { PermissionService } from './permission';
import { PagePermissionResolver, WorkspaceResolver } from './resolver';
@Module({
imports: [DocModule],
imports: [DocModule, QuotaModule],
controllers: [WorkspacesController],
providers: [
WorkspaceResolver,

View File

@ -42,6 +42,7 @@ import { DocID } from '../../utils/doc';
import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service';
import { QuotaManagementService } from '../quota';
import { UsersService } from '../users';
import { UserType } from '../users/resolver';
import { PermissionService, PublicPageMode } from './permission';
@ -148,6 +149,7 @@ export class WorkspaceResolver {
private readonly permissions: PermissionService,
private readonly users: UsersService,
private readonly event: EventEmitter,
private readonly quota: QuotaManagementService,
@Inject(StorageProvide) private readonly storage: Storage
) {}
@ -233,6 +235,14 @@ export class WorkspaceResolver {
}));
}
@ResolveField(() => Int, {
description: 'Blobs size of workspace',
complexity: 2,
})
async blobsSize(@Parent() workspace: WorkspaceType) {
return this.storage.blobsSize([workspace.id]);
}
@Query(() => Boolean, {
description: 'Get is owner of workspace',
complexity: 2,
@ -656,36 +666,9 @@ export class WorkspaceResolver {
return this.storage.listBlobs(workspaceId);
}
@Query(() => WorkspaceBlobSizes)
async collectBlobSizes(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
return this.storage.blobsSize([workspaceId]).then(size => ({ size }));
}
@Query(() => WorkspaceBlobSizes)
async collectAllBlobSizes(@CurrentUser() user: UserType) {
const workspaces = await this.prisma.workspaceUserPermission
.findMany({
where: {
userId: user.id,
accepted: true,
type: Permission.Owner,
},
select: {
workspace: {
select: {
id: true,
},
},
},
})
.then(data => data.map(({ workspace }) => workspace.id));
const size = await this.storage.blobsSize(workspaces);
const size = await this.quota.getUserUsage(user.id);
return { size };
}
@ -693,7 +676,7 @@ export class WorkspaceResolver {
async checkBlobSize(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('size', { type: () => Float }) size: number
@Args('size', { type: () => Float }) blobSize: number
) {
const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId,
@ -701,13 +684,8 @@ export class WorkspaceResolver {
Permission.Write
);
if (canWrite) {
const { user } = await this.permissions.getWorkspaceOwner(workspaceId);
if (user) {
const quota = await this.users.getStorageQuotaById(user.id);
const { size: currentSize } = await this.collectAllBlobSizes(user);
return { size: quota - (size + currentSize) };
}
const size = await this.quota.checkBlobQuota(workspaceId, blobSize);
return { size };
}
return false;
}
@ -725,14 +703,12 @@ export class WorkspaceResolver {
Permission.Write
);
// quota was apply to owner's account
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) return new NotFoundException('Workspace owner not found');
const quota = await this.users.getStorageQuotaById(owner.id);
const { size } = await this.collectAllBlobSizes(owner);
const { quota, size } = await this.quota.getWorkspaceUsage(workspaceId);
const checkExceeded = (recvSize: number) => {
if (!quota) {
throw new ForbiddenException('cannot find user quota');
}
if (size + recvSize > quota) {
this.logger.log(
`storage size limit exceeded: ${size + recvSize} > ${quota}`

View File

@ -51,17 +51,6 @@ type RemoveAvatar {
success: Boolean!
}
type AddToNewFeaturesWaitingList {
email: String!
"""New features kind"""
type: NewFeaturesKind!
}
enum NewFeaturesKind {
EarlyAccess
}
type TokenType {
token: String!
refresh: String!
@ -196,6 +185,9 @@ type WorkspaceType {
"""Owner of workspace"""
owner: UserType!
"""Blobs size of workspace"""
blobsSize: Int!
"""Shared pages of workspace"""
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
@ -269,7 +261,6 @@ type Query {
"""List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]!
collectBlobSizes(workspaceId: String!): WorkspaceBlobSizes!
collectAllBlobSizes: WorkspaceBlobSizes!
checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes!
@ -278,6 +269,7 @@ type Query {
"""Get user by email"""
user(email: String!): UserType
listEarlyAccess: [UserType!]!
prices: [SubscriptionPrice!]!
}
@ -315,7 +307,8 @@ type Mutation {
"""Remove user avatar"""
removeAvatar: RemoveAvatar!
deleteAccount: DeleteAccount!
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
addToEarlyAccess(email: String!): Int!
removeEarlyAccess(email: String!): Int!
"""Create a subscription checkout link of stripe"""
checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String!

View File

@ -45,6 +45,13 @@ class FakePrisma {
},
};
}
get newFeaturesWaitingList() {
return {
async findUnique() {
return null;
},
};
}
}
test.beforeEach(async t => {
@ -119,6 +126,7 @@ test('should find default user', async t => {
})
.expect(200)
.expect(res => {
console.log(res.body);
t.is(res.body.data.user.email, 'alex.yang@example.org');
});
});

View File

@ -9,6 +9,11 @@ import ava, { type TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../src/app';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { MailService } from '../src/modules/auth/mailer';
import { AuthService } from '../src/modules/auth/service';
import {
@ -37,6 +42,7 @@ test.beforeEach(async t => {
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
providers: [RevertCommand, RunCommand],
}).compile();
const app = module.createNestApplication();
app.use(
@ -52,6 +58,13 @@ test.beforeEach(async t => {
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach(async t => {

View File

@ -4,6 +4,11 @@ import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { ConfigModule } from '../src/config';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { GqlModule } from '../src/graphql.module';
import { AuthModule } from '../src/modules/auth';
import { AuthResolver } from '../src/modules/auth/resolver';
@ -40,10 +45,19 @@ test.beforeEach(async () => {
GqlModule,
AuthModule,
RateLimiterModule,
RevertCommand,
RunCommand,
],
}).compile();
authService = module.get(AuthService);
authResolver = module.get(AuthResolver);
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach.always(async () => {

View File

@ -11,6 +11,11 @@ import { PrismaClient } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import { ConfigModule } from '../src/config';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { GqlModule } from '../src/graphql.module';
import { AuthModule } from '../src/modules/auth';
import { AuthService } from '../src/modules/auth/service';
@ -45,8 +50,16 @@ test.beforeEach(async t => {
AuthModule,
RateLimiterModule,
],
providers: [RevertCommand, RunCommand],
}).compile();
t.context.auth = t.context.module.get(AuthService);
// init features
const run = t.context.module.get(RunCommand);
const revert = t.context.module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach.always(async t => {

View File

@ -0,0 +1,153 @@
/// <reference types="../src/global.d.ts" />
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import { ConfigModule } from '../src/config';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { AuthModule } from '../src/modules/auth';
import { AuthService } from '../src/modules/auth/service';
import {
QuotaManagementService,
QuotaModule,
Quotas,
QuotaService,
QuotaType,
} from '../src/modules/quota';
import { PrismaModule } from '../src/prisma';
import { StorageModule } from '../src/storage';
import { RateLimiterModule } from '../src/throttler';
const test = ava as TestFn<{
auth: AuthService;
quota: QuotaService;
storageQuota: QuotaManagementService;
app: TestingModule;
}>;
// cleanup database before each test
test.beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.$disconnect();
});
test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
host: 'example.org',
https: true,
}),
StorageModule.forRoot(),
PrismaModule,
AuthModule,
QuotaModule,
RateLimiterModule,
RevertCommand,
RunCommand,
],
}).compile();
const quota = module.get(QuotaService);
const storageQuota = module.get(QuotaManagementService);
const auth = module.get(AuthService);
t.context.app = module;
t.context.quota = quota;
t.context.storageQuota = storageQuota;
t.context.auth = auth;
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to set quota', async t => {
const { auth, quota } = t.context;
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
const q1 = await quota.getUserQuota(u1.id);
t.truthy(q1, 'should have quota');
t.is(q1?.feature.feature, QuotaType.Quota_FreePlanV1, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1);
const q2 = await quota.getUserQuota(u1.id);
t.is(q2?.feature.feature, QuotaType.Quota_ProPlanV1, 'should be pro plan');
const fail = quota.switchUserQuota(u1.id, 'not_exists_plan_v1' as QuotaType);
await t.throwsAsync(fail, { instanceOf: Error }, 'should throw error');
});
test('should be able to check storage quota', async t => {
const { auth, quota, storageQuota } = t.context;
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
const q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1);
const q2 = await storageQuota.getUserQuota(u1.id);
t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan');
t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan');
});
test('should be able revert quota', async t => {
const { auth, quota, storageQuota } = t.context;
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
const q1 = await storageQuota.getUserQuota(u1.id);
t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1);
const q2 = await storageQuota.getUserQuota(u1.id);
t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan');
t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan');
await quota.switchUserQuota(u1.id, QuotaType.Quota_FreePlanV1);
const q3 = await storageQuota.getUserQuota(u1.id);
t.is(q3?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan');
const quotas = await quota.getUserQuotas(u1.id);
t.is(quotas.length, 3, 'should have 3 quotas');
t.is(
quotas[0].feature.feature,
QuotaType.Quota_FreePlanV1,
'should be free plan'
);
t.is(
quotas[1].feature.feature,
QuotaType.Quota_ProPlanV1,
'should be pro plan'
);
t.is(
quotas[2].feature.feature,
QuotaType.Quota_FreePlanV1,
'should be free plan'
);
t.is(quotas[0].activated, false, 'should be activated');
t.is(quotas[1].activated, false, 'should be activated');
t.is(quotas[2].activated, true, 'should be activated');
});

View File

@ -324,7 +324,7 @@ async function listBlobs(
return res.body.data.listBlobs;
}
async function collectBlobSizes(
async function getWorkspaceBlobsSize(
app: INestApplication,
token: string,
workspaceId: string
@ -335,14 +335,14 @@ async function collectBlobSizes(
.send({
query: `
query {
collectBlobSizes(workspaceId: "${workspaceId}") {
size
workspace(id: "${workspaceId}") {
blobsSize
}
}
`,
})
.expect(200);
return res.body.data.collectBlobSizes.size;
return res.body.data.workspace.blobsSize;
}
async function collectAllBlobSizes(
@ -566,13 +566,13 @@ export {
changeEmail,
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createWorkspace,
currentUser,
flushDB,
getInviteInfo,
getPublicWorkspace,
getWorkspace,
getWorkspaceBlobsSize,
inviteUser,
leaveWorkspace,
listBlobs,

View File

@ -6,17 +6,24 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { QuotaService, QuotaType } from '../src/modules/quota';
import {
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createWorkspace,
getWorkspaceBlobsSize,
listBlobs,
setBlob,
signUp,
} from './utils';
let app: INestApplication;
let quota: QuotaService;
const client = new PrismaClient();
@ -33,6 +40,7 @@ test.beforeEach(async () => {
test.beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
providers: [RevertCommand, RunCommand],
}).compile();
app = module.createNestApplication();
app.use(
@ -41,6 +49,15 @@ test.beforeEach(async () => {
maxFiles: 5,
})
);
quota = module.get(QuotaService);
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
await app.init();
});
@ -103,7 +120,7 @@ test('should calc blobs size', async t => {
const buffer2 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace.id, buffer2);
const size = await collectBlobSizes(app, u1.token.token, workspace.id);
const size = await getWorkspaceBlobsSize(app, u1.token.token, workspace.id);
t.is(size, 4, 'failed to collect blob sizes');
});
@ -143,3 +160,39 @@ test('should calc all blobs size', async t => {
);
t.is(size2, -1, 'failed to check blob size');
});
test('should be able calc quota after switch plan', async t => {
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
const workspace1 = await createWorkspace(app, u1.token.token);
const buffer1 = Buffer.from([0, 0]);
await setBlob(app, u1.token.token, workspace1.id, buffer1);
const buffer2 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace1.id, buffer2);
const workspace2 = await createWorkspace(app, u1.token.token);
const buffer3 = Buffer.from([0, 0]);
await setBlob(app, u1.token.token, workspace2.id, buffer3);
const buffer4 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace2.id, buffer4);
const size1 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
10 * 1024 * 1024 * 1024 - 8
);
t.is(size1, 0, 'failed to check free plan blob size');
quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1);
const size2 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
100 * 1024 * 1024 * 1024 - 8
);
t.is(size2, 0, 'failed to check pro plan blob size');
});

View File

@ -9,6 +9,11 @@ import ava, { type TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../src/app';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import { MailService } from '../src/modules/auth/mailer';
import { AuthService } from '../src/modules/auth/service';
import {
@ -39,6 +44,7 @@ test.beforeEach(async t => {
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
providers: [RevertCommand, RunCommand],
}).compile();
const app = module.createNestApplication();
app.use(
@ -51,9 +57,17 @@ test.beforeEach(async t => {
const auth = module.get(AuthService);
const mail = module.get(MailService);
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach.always(async t => {

View File

@ -4,6 +4,7 @@ import ava, { type TestFn } from 'ava';
import { stub } from 'sinon';
import { AppModule } from '../src/app';
import { Quotas } from '../src/modules/quota';
import { UsersService } from '../src/modules/users';
import { PermissionService } from '../src/modules/workspaces/permission';
import { WorkspaceResolver } from '../src/modules/workspaces/resolver';
@ -20,6 +21,9 @@ class FakePermission {
user: new FakePrisma().fakeUser,
};
}
async getOwnedWorkspaces() {
return [''];
}
}
const fakeUserService = {
@ -42,6 +46,19 @@ test.beforeEach(async t => {
return [];
},
},
userFeatures: {
async count() {
return 1;
},
async findFirst() {
return {
createdAt: new Date(),
expiredAt: new Date(),
reason: '',
feature: Quotas[0],
};
},
},
})
.overrideProvider(PermissionService)
.useClass(FakePermission)

View File

@ -6,6 +6,11 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import {
collectMigrations,
RevertCommand,
RunCommand,
} from '../src/data/commands/run';
import {
acceptInviteById,
createWorkspace,
@ -34,6 +39,7 @@ test.beforeEach(async t => {
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
providers: [RevertCommand, RunCommand],
}).compile();
const app = module.createNestApplication();
app.use(
@ -45,6 +51,13 @@ test.beforeEach(async t => {
await app.init();
t.context.client = client;
t.context.app = app;
// init features
const run = module.get(RunCommand);
const revert = module.get(RevertCommand);
const migrations = await collectMigrations();
await Promise.allSettled(migrations.map(m => revert.run([m.name])));
await run.run();
});
test.afterEach.always(async t => {

View File

@ -1,5 +1,5 @@
query blobSizes($workspaceId: String!) {
collectBlobSizes(workspaceId: $workspaceId) {
size
workspace(id: $workspaceId) {
blobsSize
}
}

View File

@ -56,12 +56,12 @@ mutation setBlob($workspaceId: String!, $blob: Upload!) {
export const blobSizesQuery = {
id: 'blobSizesQuery' as const,
operationName: 'blobSizes',
definitionName: 'collectBlobSizes',
definitionName: 'workspace',
containsFile: false,
query: `
query blobSizes($workspaceId: String!) {
collectBlobSizes(workspaceId: $workspaceId) {
size
workspace(id: $workspaceId) {
blobsSize
}
}`,
};

View File

@ -40,10 +40,6 @@ export enum InvoiceStatus {
Void = 'Void',
}
export enum NewFeaturesKind {
EarlyAccess = 'EarlyAccess',
}
/** User permission in workspace */
export enum Permission {
Admin = 'Admin',
@ -127,7 +123,7 @@ export type BlobSizesQueryVariables = Exact<{
export type BlobSizesQuery = {
__typename?: 'Query';
collectBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number };
workspace: { __typename?: 'WorkspaceType'; blobsSize: number };
};
export type AllBlobSizesQueryVariables = Exact<{ [key: string]: never }>;

View File

@ -107,6 +107,20 @@ export async function createRandomUser(): Promise<{
...user,
emailVerified: new Date(),
password: await hash(user.password),
features: {
create: {
reason: 'created by test case',
activated: true,
feature: {
connect: {
feature_version: {
feature: 'free_plan_v1',
version: 1,
},
},
},
},
},
},
});

462
yarn.lock
View File

@ -2487,7 +2487,30 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.14.0, @babel/core@npm:^7.18.9, @babel/core@npm:^7.20.12, @babel/core@npm:^7.20.7, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.5, @babel/core@npm:^7.22.9, @babel/core@npm:^7.23.3, @babel/core@npm:^7.7.5":
"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.14.0, @babel/core@npm:^7.20.12, @babel/core@npm:^7.20.7, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.5, @babel/core@npm:^7.22.9, @babel/core@npm:^7.7.5":
version: 7.23.2
resolution: "@babel/core@npm:7.23.2"
dependencies:
"@ampproject/remapping": "npm:^2.2.0"
"@babel/code-frame": "npm:^7.22.13"
"@babel/generator": "npm:^7.23.0"
"@babel/helper-compilation-targets": "npm:^7.22.15"
"@babel/helper-module-transforms": "npm:^7.23.0"
"@babel/helpers": "npm:^7.23.2"
"@babel/parser": "npm:^7.23.0"
"@babel/template": "npm:^7.22.15"
"@babel/traverse": "npm:^7.23.2"
"@babel/types": "npm:^7.23.0"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: b69d7008695b2ac7a3a2db83c5c712fbb79f7031c4480f6351cde327930e38873003d1d021059b729a1d0cb48093f1d384c64269b78f6189f50051fe4f64dc2d
languageName: node
linkType: hard
"@babel/core@npm:^7.18.9, @babel/core@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/core@npm:7.23.3"
dependencies:
@ -2522,6 +2545,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.23.0":
version: 7.23.0
resolution: "@babel/generator@npm:7.23.0"
dependencies:
"@babel/types": "npm:^7.23.0"
"@jridgewell/gen-mapping": "npm:^0.3.2"
"@jridgewell/trace-mapping": "npm:^0.3.17"
jsesc: "npm:^2.5.1"
checksum: bd1598bd356756065d90ce26968dd464ac2b915c67623f6f071fb487da5f9eb454031a380e20e7c9a7ce5c4a49d23be6cb9efde404952b0b3f3c0c3a9b73d68a
languageName: node
linkType: hard
"@babel/generator@npm:^7.23.6":
version: 7.23.6
resolution: "@babel/generator@npm:7.23.6"
@ -2656,6 +2691,21 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.23.0":
version: 7.23.0
resolution: "@babel/helper-module-transforms@npm:7.23.0"
dependencies:
"@babel/helper-environment-visitor": "npm:^7.22.20"
"@babel/helper-module-imports": "npm:^7.22.15"
"@babel/helper-simple-access": "npm:^7.22.5"
"@babel/helper-split-export-declaration": "npm:^7.22.6"
"@babel/helper-validator-identifier": "npm:^7.22.20"
peerDependencies:
"@babel/core": ^7.0.0
checksum: d72fe444f7b6c5aadaac8f393298d603eedd48e5dead67273a48e5c83a677cbccbd8a12a06c5bf5d97924666083279158a4bd0e799d28b86cbbfacba9e41f598
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/helper-module-transforms@npm:7.23.3"
@ -2740,6 +2790,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-string-parser@npm:^7.22.5":
version: 7.22.5
resolution: "@babel/helper-string-parser@npm:7.22.5"
checksum: 7f275a7f1a9504da06afc33441e219796352a4a3d0288a961bc14d1e30e06833a71621b33c3e60ee3ac1ff3c502d55e392bcbc0665f6f9d2629809696fab7cdd
languageName: node
linkType: hard
"@babel/helper-string-parser@npm:^7.23.4":
version: 7.23.4
resolution: "@babel/helper-string-parser@npm:7.23.4"
@ -4089,6 +4146,24 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.23.2":
version: 7.23.2
resolution: "@babel/traverse@npm:7.23.2"
dependencies:
"@babel/code-frame": "npm:^7.22.13"
"@babel/generator": "npm:^7.23.0"
"@babel/helper-environment-visitor": "npm:^7.22.20"
"@babel/helper-function-name": "npm:^7.23.0"
"@babel/helper-hoist-variables": "npm:^7.22.5"
"@babel/helper-split-export-declaration": "npm:^7.22.6"
"@babel/parser": "npm:^7.23.0"
"@babel/types": "npm:^7.23.0"
debug: "npm:^4.1.0"
globals: "npm:^11.1.0"
checksum: e4fcb8f8395804956df4ae1301230a14b6eb35b74a7058a0e0b40f6f4be7281e619e6dafe400e833d4512da5d61cf17ea177d04b00a8f7cf3d8d69aff83ca3d8
languageName: node
linkType: hard
"@babel/traverse@npm:^7.23.6":
version: 7.23.6
resolution: "@babel/traverse@npm:7.23.6"
@ -4107,7 +4182,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.16.8, @babel/types@npm:^7.17.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.3, @babel/types@npm:^7.23.4, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.16.8, @babel/types@npm:^7.17.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
version: 7.23.0
resolution: "@babel/types@npm:7.23.0"
dependencies:
"@babel/helper-string-parser": "npm:^7.22.5"
"@babel/helper-validator-identifier": "npm:^7.22.20"
to-fast-properties: "npm:^2.0.0"
checksum: ca5b896a26c91c5672254725c4c892a35567d2122afc47bd5331d1611a7f9230c19fc9ef591a5a6f80bf0d80737e104a9ac205c96447c74bee01d4319db58001
languageName: node
linkType: hard
"@babel/types@npm:^7.18.9, @babel/types@npm:^7.23.3, @babel/types@npm:^7.23.4":
version: 7.23.4
resolution: "@babel/types@npm:7.23.4"
dependencies:
@ -5318,6 +5404,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/android-arm64@npm:0.19.7"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/android-arm64@npm:0.19.8"
@ -5346,6 +5439,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/android-arm@npm:0.19.7"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/android-arm@npm:0.19.8"
@ -5374,6 +5474,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/android-x64@npm:0.19.7"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/android-x64@npm:0.19.8"
@ -5402,6 +5509,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/darwin-arm64@npm:0.19.7"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/darwin-arm64@npm:0.19.8"
@ -5430,6 +5544,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/darwin-x64@npm:0.19.7"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/darwin-x64@npm:0.19.8"
@ -5458,6 +5579,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/freebsd-arm64@npm:0.19.7"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/freebsd-arm64@npm:0.19.8"
@ -5486,6 +5614,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/freebsd-x64@npm:0.19.7"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/freebsd-x64@npm:0.19.8"
@ -5514,6 +5649,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-arm64@npm:0.19.7"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-arm64@npm:0.19.8"
@ -5542,6 +5684,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-arm@npm:0.19.7"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-arm@npm:0.19.8"
@ -5570,6 +5719,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-ia32@npm:0.19.7"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-ia32@npm:0.19.8"
@ -5598,6 +5754,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-loong64@npm:0.19.7"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-loong64@npm:0.19.8"
@ -5626,6 +5789,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-mips64el@npm:0.19.7"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-mips64el@npm:0.19.8"
@ -5654,6 +5824,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-ppc64@npm:0.19.7"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-ppc64@npm:0.19.8"
@ -5682,6 +5859,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-riscv64@npm:0.19.7"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-riscv64@npm:0.19.8"
@ -5710,6 +5894,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-s390x@npm:0.19.7"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-s390x@npm:0.19.8"
@ -5738,6 +5929,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/linux-x64@npm:0.19.7"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/linux-x64@npm:0.19.8"
@ -5766,6 +5964,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/netbsd-x64@npm:0.19.7"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/netbsd-x64@npm:0.19.8"
@ -5794,6 +5999,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/openbsd-x64@npm:0.19.7"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/openbsd-x64@npm:0.19.8"
@ -5822,6 +6034,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/sunos-x64@npm:0.19.7"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/sunos-x64@npm:0.19.8"
@ -5850,6 +6069,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/win32-arm64@npm:0.19.7"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/win32-arm64@npm:0.19.8"
@ -5878,6 +6104,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/win32-ia32@npm:0.19.7"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/win32-ia32@npm:0.19.8"
@ -5906,6 +6139,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.19.7":
version: 0.19.7
resolution: "@esbuild/win32-x64@npm:0.19.7"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.19.8":
version: 0.19.8
resolution: "@esbuild/win32-x64@npm:0.19.8"
@ -6692,7 +6932,20 @@ __metadata:
languageName: node
linkType: hard
"@graphql-tools/utils@npm:^10.0.0, @graphql-tools/utils@npm:^10.0.10, @graphql-tools/utils@npm:^10.0.11, @graphql-tools/utils@npm:^10.0.2, @graphql-tools/utils@npm:^10.0.5, @graphql-tools/utils@npm:^10.0.8":
"@graphql-tools/utils@npm:^10.0.0, @graphql-tools/utils@npm:^10.0.2, @graphql-tools/utils@npm:^10.0.5":
version: 10.0.7
resolution: "@graphql-tools/utils@npm:10.0.7"
dependencies:
"@graphql-typed-document-node/core": "npm:^3.1.1"
dset: "npm:^3.1.2"
tslib: "npm:^2.4.0"
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
checksum: 50383782f7e2667f44891f060a0b91f1c551becccf919f041e0ce70bafd42021bf8b446273ce2b3efcd2de53d0b59b99f954c2f4094041fba86d478f616a30ea
languageName: node
linkType: hard
"@graphql-tools/utils@npm:^10.0.10, @graphql-tools/utils@npm:^10.0.11, @graphql-tools/utils@npm:^10.0.8":
version: 10.0.11
resolution: "@graphql-tools/utils@npm:10.0.11"
dependencies:
@ -11799,6 +12052,16 @@ __metadata:
languageName: node
linkType: hard
"@smithy/abort-controller@npm:^2.0.12":
version: 2.0.12
resolution: "@smithy/abort-controller@npm:2.0.12"
dependencies:
"@smithy/types": "npm:^2.4.0"
tslib: "npm:^2.5.0"
checksum: ade23e7e6d3b30615cb376e2578b7f9545a2e0c1ab67f570a76ce5dde3547c6dde0964976e3e914f4844df0dd0ddf9ddc38820ba69f61eed2fffe6e563d0c4e4
languageName: node
linkType: hard
"@smithy/abort-controller@npm:^2.0.14":
version: 2.0.14
resolution: "@smithy/abort-controller@npm:2.0.14"
@ -12061,7 +12324,7 @@ __metadata:
languageName: node
linkType: hard
"@smithy/node-http-handler@npm:^2.1.10, @smithy/node-http-handler@npm:^2.1.8, @smithy/node-http-handler@npm:^2.1.9":
"@smithy/node-http-handler@npm:^2.1.10, @smithy/node-http-handler@npm:^2.1.9":
version: 2.1.10
resolution: "@smithy/node-http-handler@npm:2.1.10"
dependencies:
@ -12074,6 +12337,19 @@ __metadata:
languageName: node
linkType: hard
"@smithy/node-http-handler@npm:^2.1.8":
version: 2.1.8
resolution: "@smithy/node-http-handler@npm:2.1.8"
dependencies:
"@smithy/abort-controller": "npm:^2.0.12"
"@smithy/protocol-http": "npm:^3.0.8"
"@smithy/querystring-builder": "npm:^2.0.12"
"@smithy/types": "npm:^2.4.0"
tslib: "npm:^2.5.0"
checksum: aca079234edc6d8946df0408949af3eee0f862225e6ebafcd72123b96f087213e2a4f7bb71d6d6a21eebc78dae636f5c999c91700f7577c6ba61998f05b070ae
languageName: node
linkType: hard
"@smithy/property-provider@npm:^2.0.0, @smithy/property-provider@npm:^2.0.15":
version: 2.0.15
resolution: "@smithy/property-provider@npm:2.0.15"
@ -12084,7 +12360,7 @@ __metadata:
languageName: node
linkType: hard
"@smithy/protocol-http@npm:^3.0.10, @smithy/protocol-http@npm:^3.0.8, @smithy/protocol-http@npm:^3.0.9":
"@smithy/protocol-http@npm:^3.0.10, @smithy/protocol-http@npm:^3.0.9":
version: 3.0.10
resolution: "@smithy/protocol-http@npm:3.0.10"
dependencies:
@ -12094,6 +12370,27 @@ __metadata:
languageName: node
linkType: hard
"@smithy/protocol-http@npm:^3.0.8":
version: 3.0.8
resolution: "@smithy/protocol-http@npm:3.0.8"
dependencies:
"@smithy/types": "npm:^2.4.0"
tslib: "npm:^2.5.0"
checksum: 014df5fe50231434b5227b8359f31d925de77c581d576170b4d62fdd64cb3c24b35aeec636f229aba3cd303f32a12e0c1be3355af883dbe73f995e4b975ac0f7
languageName: node
linkType: hard
"@smithy/querystring-builder@npm:^2.0.12":
version: 2.0.12
resolution: "@smithy/querystring-builder@npm:2.0.12"
dependencies:
"@smithy/types": "npm:^2.4.0"
"@smithy/util-uri-escape": "npm:^2.0.0"
tslib: "npm:^2.5.0"
checksum: e3ba93e7195b6240b052ff88833685f926ee14191880214bf7c073aae5315e4956b57762a96745e2bd2f1d2bc7f2fa66f797400a739fdde7c13bed83d2c56cdf
languageName: node
linkType: hard
"@smithy/querystring-builder@npm:^2.0.14":
version: 2.0.14
resolution: "@smithy/querystring-builder@npm:2.0.14"
@ -14604,7 +14901,7 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash-es@npm:^4.17.11, @types/lodash-es@npm:^4.17.6, @types/lodash-es@npm:^4.17.9":
"@types/lodash-es@npm:^4.17.11":
version: 4.17.12
resolution: "@types/lodash-es@npm:4.17.12"
dependencies:
@ -14613,6 +14910,15 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash-es@npm:^4.17.6, @types/lodash-es@npm:^4.17.9":
version: 4.17.9
resolution: "@types/lodash-es@npm:4.17.9"
dependencies:
"@types/lodash": "npm:*"
checksum: 5e3a8a74134e67c37f1b8eb4a2897c88038f1b1bd7f508feec9e5561b52787d7efcc30c18981e9c6edec2b894f127b60312a431d98b84e12e785bea9cb5d1d40
languageName: node
linkType: hard
"@types/lodash.debounce@npm:^4.0.7":
version: 4.0.9
resolution: "@types/lodash.debounce@npm:4.0.9"
@ -14747,7 +15053,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^18.0.0, @types/node@npm:^18.11.18, @types/node@npm:^18.11.9":
"@types/node@npm:^18.0.0, @types/node@npm:^18.11.9":
version: 18.18.13
resolution: "@types/node@npm:18.18.13"
dependencies:
@ -14756,6 +15062,13 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^18.11.18":
version: 18.18.5
resolution: "@types/node@npm:18.18.5"
checksum: a7363aab9f402290799d3e2696fbc70c76a8a65e2354f72b8f399c38edc346f600066f8ac59dde985cfc64160cfeb63ed7fc917aecdfe7ec469345d3ce029bda
languageName: node
linkType: hard
"@types/nodemailer@npm:^6.4.14":
version: 6.4.14
resolution: "@types/nodemailer@npm:6.4.14"
@ -15077,7 +15390,16 @@ __metadata:
languageName: node
linkType: hard
"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.10, @types/ws@npm:^8.5.5, @types/ws@npm:^8.5.7":
"@types/ws@npm:^8.0.0, @types/ws@npm:^8.5.5, @types/ws@npm:^8.5.7":
version: 8.5.7
resolution: "@types/ws@npm:8.5.7"
dependencies:
"@types/node": "npm:*"
checksum: 48e426be74d6bdc176c06f98cc96f7fc91dba10aaf88c87108b57e1dba588f4607dcd062d7a83686a3857dc7af09fdd420d8a816c0306cb0362ece2f0e37983c
languageName: node
linkType: hard
"@types/ws@npm:^8.5.10":
version: 8.5.10
resolution: "@types/ws@npm:8.5.10"
dependencies:
@ -16128,7 +16450,16 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.10.0, acorn@npm:^8.11.2, acorn@npm:^8.4.1, acorn@npm:^8.6.0, acorn@npm:^8.7.1, acorn@npm:^8.8.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0":
"acorn@npm:^8.10.0, acorn@npm:^8.4.1, acorn@npm:^8.7.1, acorn@npm:^8.8.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0":
version: 8.10.0
resolution: "acorn@npm:8.10.0"
bin:
acorn: bin/acorn
checksum: 522310c20fdc3c271caed3caf0f06c51d61cb42267279566edd1d58e83dbc12eebdafaab666a0f0be1b7ad04af9c6bc2a6f478690a9e6391c3c8b165ada917dd
languageName: node
linkType: hard
"acorn@npm:^8.11.2, acorn@npm:^8.6.0":
version: 8.11.2
resolution: "acorn@npm:8.11.2"
bin:
@ -20758,7 +21089,7 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:^0.19.3, esbuild@npm:^0.19.7":
"esbuild@npm:^0.19.3":
version: 0.19.8
resolution: "esbuild@npm:0.19.8"
dependencies:
@ -20835,6 +21166,83 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:^0.19.7":
version: 0.19.7
resolution: "esbuild@npm:0.19.7"
dependencies:
"@esbuild/android-arm": "npm:0.19.7"
"@esbuild/android-arm64": "npm:0.19.7"
"@esbuild/android-x64": "npm:0.19.7"
"@esbuild/darwin-arm64": "npm:0.19.7"
"@esbuild/darwin-x64": "npm:0.19.7"
"@esbuild/freebsd-arm64": "npm:0.19.7"
"@esbuild/freebsd-x64": "npm:0.19.7"
"@esbuild/linux-arm": "npm:0.19.7"
"@esbuild/linux-arm64": "npm:0.19.7"
"@esbuild/linux-ia32": "npm:0.19.7"
"@esbuild/linux-loong64": "npm:0.19.7"
"@esbuild/linux-mips64el": "npm:0.19.7"
"@esbuild/linux-ppc64": "npm:0.19.7"
"@esbuild/linux-riscv64": "npm:0.19.7"
"@esbuild/linux-s390x": "npm:0.19.7"
"@esbuild/linux-x64": "npm:0.19.7"
"@esbuild/netbsd-x64": "npm:0.19.7"
"@esbuild/openbsd-x64": "npm:0.19.7"
"@esbuild/sunos-x64": "npm:0.19.7"
"@esbuild/win32-arm64": "npm:0.19.7"
"@esbuild/win32-ia32": "npm:0.19.7"
"@esbuild/win32-x64": "npm:0.19.7"
dependenciesMeta:
"@esbuild/android-arm":
optional: true
"@esbuild/android-arm64":
optional: true
"@esbuild/android-x64":
optional: true
"@esbuild/darwin-arm64":
optional: true
"@esbuild/darwin-x64":
optional: true
"@esbuild/freebsd-arm64":
optional: true
"@esbuild/freebsd-x64":
optional: true
"@esbuild/linux-arm":
optional: true
"@esbuild/linux-arm64":
optional: true
"@esbuild/linux-ia32":
optional: true
"@esbuild/linux-loong64":
optional: true
"@esbuild/linux-mips64el":
optional: true
"@esbuild/linux-ppc64":
optional: true
"@esbuild/linux-riscv64":
optional: true
"@esbuild/linux-s390x":
optional: true
"@esbuild/linux-x64":
optional: true
"@esbuild/netbsd-x64":
optional: true
"@esbuild/openbsd-x64":
optional: true
"@esbuild/sunos-x64":
optional: true
"@esbuild/win32-arm64":
optional: true
"@esbuild/win32-ia32":
optional: true
"@esbuild/win32-x64":
optional: true
bin:
esbuild: bin/esbuild
checksum: 326b9d98a77c5f2fb9a535b292bdc67c88bdfb4a19d29a221d65fd69f4800faea1f34947e8e6bc25ca3bd5db01f61c6968fec91f8c335e21e29b50330d90bd89
languageName: node
linkType: hard
"escalade@npm:^3.1.1":
version: 3.1.1
resolution: "escalade@npm:3.1.1"
@ -23081,7 +23489,7 @@ __metadata:
languageName: node
linkType: hard
"graphql-ws@npm:5.14.2, graphql-ws@npm:^5.14.0":
"graphql-ws@npm:5.14.2":
version: 5.14.2
resolution: "graphql-ws@npm:5.14.2"
peerDependencies:
@ -23090,6 +23498,15 @@ __metadata:
languageName: node
linkType: hard
"graphql-ws@npm:^5.14.0":
version: 5.14.1
resolution: "graphql-ws@npm:5.14.1"
peerDependencies:
graphql: ">=0.11 <=16"
checksum: d3b0917df3ae20aa65b5193527f7005cdce35d7c59856adc3aad2ff128952b9f6f207c0cc4f92bb5d7b5210d458243a93fb3e58339253ed830a5b17619d21ea8
languageName: node
linkType: hard
"graphql@npm:0.13.1 - 16, graphql@npm:^16.0.0, graphql@npm:^16.8.1":
version: 16.8.1
resolution: "graphql@npm:16.8.1"
@ -26223,7 +26640,19 @@ __metadata:
languageName: node
linkType: hard
"lib0@npm:^0.2.74, lib0@npm:^0.2.85, lib0@npm:^0.2.86, lib0@npm:^0.2.87, lib0@npm:^0.2.88":
"lib0@npm:^0.2.74, lib0@npm:^0.2.85, lib0@npm:^0.2.87":
version: 0.2.87
resolution: "lib0@npm:0.2.87"
dependencies:
isomorphic.js: "npm:^0.2.4"
bin:
0gentesthtml: bin/gentesthtml.js
0serve: bin/0serve.js
checksum: 078a55d1a6eb85a6fe836cf8c1268fa4761e475679db7f31c4993acd8a3a15e16c20e4481da9239402ff8a02a3718be9572767b3d6759e23a3518bcc0cc6b520
languageName: node
linkType: hard
"lib0@npm:^0.2.86, lib0@npm:^0.2.88":
version: 0.2.88
resolution: "lib0@npm:0.2.88"
dependencies:
@ -37089,7 +37518,7 @@ __metadata:
languageName: node
linkType: hard
"yaml@npm:2.3.4, yaml@npm:^2.2.1, yaml@npm:^2.3.1, yaml@npm:^2.3.4":
"yaml@npm:2.3.4, yaml@npm:^2.3.4":
version: 2.3.4
resolution: "yaml@npm:2.3.4"
checksum: f8207ce43065a22268a2806ea6a0fa3974c6fde92b4b2fa0082357e487bc333e85dc518910007e7ac001b532c7c84bd3eccb6c7757e94182b564028b0008f44b
@ -37103,6 +37532,13 @@ __metadata:
languageName: node
linkType: hard
"yaml@npm:^2.2.1, yaml@npm:^2.3.1":
version: 2.3.3
resolution: "yaml@npm:2.3.3"
checksum: 3b1a974b9d3672c671d47099a41c0de77b7ff978d0849aa55a095587486e82cd072321d19f2b4c791a367f766310b5a82dff098839b0f4ddcbbbe477f82dfb07
languageName: node
linkType: hard
"yargs-parser@npm:21.1.1, yargs-parser@npm:>=21.1.1, yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"