refactor(server): config system (#11081)

This commit is contained in:
forehalo
2025-03-27 12:32:28 +00:00
parent 7091111f85
commit 0ea38680fa
274 changed files with 7583 additions and 5841 deletions

View File

@@ -0,0 +1,900 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AFFiNE Application Configuration",
"type": "object",
"properties": {
"redis": {
"type": "object",
"description": "Configuration for redis module",
"properties": {
"db": {
"type": "number",
"description": "The database index of redis server to be used(Must be less than 10).\n@default 0\n@environment `REDIS_DATABASE`",
"default": 0
},
"host": {
"type": "string",
"description": "The host of the redis server.\n@default \"localhost\"\n@environment `REDIS_HOST`",
"default": "localhost"
},
"port": {
"type": "number",
"description": "The port of the redis server.\n@default 6379\n@environment `REDIS_PORT`",
"default": 6379
},
"username": {
"type": "string",
"description": "The username of the redis server.\n@default \"\"\n@environment `REDIS_USERNAME`",
"default": ""
},
"password": {
"type": "string",
"description": "The password of the redis server.\n@default \"\"\n@environment `REDIS_PASSWORD`",
"default": ""
},
"ioredis": {
"type": "object",
"description": "The config for the ioredis client.\n@default {}\n@link https://github.com/luin/ioredis",
"default": {}
}
}
},
"metrics": {
"type": "object",
"description": "Configuration for metrics module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable metric and tracing collection\n@default false",
"default": false
}
}
},
"graphql": {
"type": "object",
"description": "Configuration for graphql module",
"properties": {
"apolloDriverConfig": {
"type": "object",
"description": "The config for underlying nestjs GraphQL and apollo driver engine.\n@default {\"buildSchemaOptions\":{\"numberScalarMode\":\"integer\"},\"useGlobalPrefix\":true,\"playground\":true,\"introspection\":true,\"sortSchema\":true}\n@link https://docs.nestjs.com/graphql/quick-start",
"default": {
"buildSchemaOptions": {
"numberScalarMode": "integer"
},
"useGlobalPrefix": true,
"playground": true,
"introspection": true,
"sortSchema": true
}
}
}
},
"crypto": {
"type": "object",
"description": "Configuration for crypto module",
"properties": {
"privateKey": {
"type": "string",
"description": "The private key for used by the crypto module to create signed tokens or encrypt data.\n@default \"\"\n@environment `AFFINE_PRIVATE_KEY`",
"default": ""
}
}
},
"job": {
"type": "object",
"description": "Configuration for job module",
"properties": {
"queue": {
"type": "object",
"description": "The config for job queues\n@default {\"prefix\":\"affine_job\",\"defaultJobOptions\":{\"attempts\":5,\"removeOnComplete\":true,\"removeOnFail\":{\"age\":86400,\"count\":500}}}\n@link https://api.docs.bullmq.io/interfaces/v5.QueueOptions.html",
"default": {
"prefix": "affine_job",
"defaultJobOptions": {
"attempts": 5,
"removeOnComplete": true,
"removeOnFail": {
"age": 86400,
"count": 500
}
}
}
},
"worker": {
"type": "object",
"description": "The config for job workers\n@default {\"defaultWorkerOptions\":{}}\n@link https://api.docs.bullmq.io/interfaces/v5.WorkerOptions.html",
"default": {
"defaultWorkerOptions": {}
}
},
"queues.copilot": {
"type": "object",
"description": "The config for copilot job queue\n@default {\"concurrency\":1}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 1
}
},
"queues.doc": {
"type": "object",
"description": "The config for doc job queue\n@default {\"concurrency\":1}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 1
}
},
"queues.notification": {
"type": "object",
"description": "The config for notification job queue\n@default {\"concurrency\":10}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 10
}
},
"queues.nightly": {
"type": "object",
"description": "The config for nightly job queue\n@default {\"concurrency\":1}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 1
}
}
}
},
"throttle": {
"type": "object",
"description": "Configuration for throttle module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the throttler is enabled.\n@default true",
"default": true
},
"throttlers.default": {
"type": "object",
"description": "The config for the default throttler.\n@default {\"ttl\":60,\"limit\":120}",
"default": {
"ttl": 60,
"limit": 120
}
},
"throttlers.strict": {
"type": "object",
"description": "The config for the strict throttler.\n@default {\"ttl\":60,\"limit\":20}",
"default": {
"ttl": 60,
"limit": 20
}
}
}
},
"websocket": {
"type": "object",
"description": "Configuration for websocket module",
"properties": {
"transports": {
"type": "array",
"description": "The enabled transports for accepting websocket traffics.\n@default [\"websocket\",\"polling\"]\n@link https://docs.nestjs.com/websockets/gateways#transports",
"items": {
"type": "string",
"enum": [
"websocket",
"polling"
]
},
"default": [
"websocket",
"polling"
]
},
"maxHttpBufferSize": {
"type": "number",
"description": "How many bytes or characters a message can be, before closing the session (to avoid DoS).\n@default 100000000",
"default": 100000000
}
}
},
"db": {
"type": "object",
"description": "Configuration for db module",
"properties": {
"datasourceUrl": {
"type": "string",
"description": "The datasource url for the prisma client.\n@default \"postgresql://localhost:5432/affine\"\n@environment `DATABASE_URL`",
"default": "postgresql://localhost:5432/affine"
},
"prisma": {
"type": "object",
"description": "The config for the prisma client.\n@default {}\n@link https://www.prisma.io/docs/reference/api-reference/prisma-client-reference",
"default": {}
}
}
},
"auth": {
"type": "object",
"description": "Configuration for auth module",
"properties": {
"allowSignup": {
"type": "boolean",
"description": "Whether allow new registrations.\n@default true",
"default": true
},
"requireEmailDomainVerification": {
"type": "boolean",
"description": "Whether require email domain record verification before accessing restricted resources.\n@default false",
"default": false
},
"requireEmailVerification": {
"type": "boolean",
"description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true",
"default": true
},
"passwordRequirements": {
"type": "object",
"description": "The password strength requirements when set new password.\n@default {\"min\":8,\"max\":32}",
"properties": {
"min": {
"type": "number"
},
"max": {
"type": "number"
}
},
"default": {
"min": 8,
"max": 32
}
},
"session.ttl": {
"type": "number",
"description": "Application auth expiration time in seconds.\n@default 1296000",
"default": 1296000
},
"session.ttr": {
"type": "number",
"description": "Application auth time to refresh in seconds.\n@default 604800",
"default": 604800
}
}
},
"mailer": {
"type": "object",
"description": "Configuration for mailer module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether enabled mail service.\n@default false",
"default": false
},
"SMTP.host": {
"type": "string",
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"\n@environment `MAILER_HOST`",
"default": ""
},
"SMTP.port": {
"type": "number",
"description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465\n@environment `MAILER_PORT`",
"default": 465
},
"SMTP.username": {
"type": "string",
"description": "Username used to authenticate the email server\n@default \"\"\n@environment `MAILER_USER`",
"default": ""
},
"SMTP.password": {
"type": "string",
"description": "Password used to authenticate the email server\n@default \"\"\n@environment `MAILER_PASSWORD`",
"default": ""
},
"SMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")\n@default \"\"\n@environment `MAILER_SENDER`",
"default": ""
},
"SMTP.ignoreTLS": {
"type": "boolean",
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false\n@environment `MAILER_IGNORE_TLS`",
"default": false
}
}
},
"doc": {
"type": "object",
"description": "Configuration for doc module",
"properties": {
"experimental.yocto": {
"type": "boolean",
"description": "Use `y-octo` to merge updates at the same time when merging using Yjs.\n@default false",
"default": false
},
"history.interval": {
"type": "number",
"description": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.\n@default 600000",
"default": 600000
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
"properties": {
"avatar.publicPath": {
"type": "string",
"description": "The public accessible path prefix for user avatars.\n@default \"/api/avatars/\"",
"default": "/api/avatars/"
},
"avatar.storage": {
"type": "object",
"description": "The config of storage for user avatars.\n@default {\"provider\":\"fs\",\"bucket\":\"avatars\",\"config\":{\"path\":\"~/.affine/storage\"}}",
"oneOf": [
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"fs"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"aws-s3"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"cloudflare-r2"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
},
"accountId": {
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
}
}
}
}
}
],
"default": {
"provider": "fs",
"bucket": "avatars",
"config": {
"path": "~/.affine/storage"
}
}
},
"blob.storage": {
"type": "object",
"description": "The config of storage for all uploaded blobs(images, videos, etc.).\n@default {\"provider\":\"fs\",\"bucket\":\"blobs\",\"config\":{\"path\":\"~/.affine/storage\"}}",
"oneOf": [
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"fs"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"aws-s3"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"cloudflare-r2"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
},
"accountId": {
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
}
}
}
}
}
],
"default": {
"provider": "fs",
"bucket": "blobs",
"config": {
"path": "~/.affine/storage"
}
}
}
}
},
"server": {
"type": "object",
"description": "Configuration for server module",
"properties": {
"name": {
"type": "string",
"description": "A recognizable name for the server. Will be shown when connected with AFFiNE Desktop.\n@default \"AFFiNE Cloud\"",
"default": "AFFiNE Cloud"
},
"externalUrl": {
"type": "string",
"description": "Base url of AFFiNE server, used for generating external urls.\nDefault to be `[server.protocol]://[server.host][:server.port]` if not specified.\n \n@default \"http://localhost:3010\"\n@environment `AFFINE_SERVER_EXTERNAL_URL`",
"default": "http://localhost:3010"
},
"https": {
"type": "boolean",
"description": "Whether the server is hosted on a ssl enabled domain (https://).\n@default false\n@environment `AFFINE_SERVER_HTTPS`",
"default": false
},
"host": {
"type": "string",
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
"default": "localhost"
},
"port": {
"type": "number",
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
"default": 3010
},
"path": {
"type": "string",
"description": "Subpath where the server get deployed if there is.\n@default \"\"\n@environment `AFFINE_SERVER_SUB_PATH`",
"default": ""
}
}
},
"flags": {
"type": "object",
"description": "Configuration for flags module",
"properties": {
"earlyAccessControl": {
"type": "boolean",
"description": "Only allow users with early access features to access the app\n@default false",
"default": false
}
}
},
"client": {
"type": "object",
"description": "Configuration for client module",
"properties": {
"versionControl.enabled": {
"type": "boolean",
"description": "Whether check version of client before accessing the server.\n@default false",
"default": false
},
"versionControl.requiredVersion": {
"type": "string",
"description": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.\n@default \">=0.20.0\"",
"default": ">=0.20.0"
}
}
},
"captcha": {
"type": "object",
"description": "Configuration for captcha module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Check captcha challenge when user authenticating the app.\n@default false",
"default": false
},
"config": {
"type": "object",
"description": "The config for the captcha plugin.\n@default {\"turnstile\":{\"secret\":\"\"},\"challenge\":{\"bits\":20}}",
"default": {
"turnstile": {
"secret": ""
},
"challenge": {
"bits": 20
}
}
}
}
},
"copilot": {
"type": "object",
"description": "Configuration for copilot module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether to enable the copilot plugin.\n@default false",
"default": false
},
"providers.openai": {
"type": "object",
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\"}\n@link https://github.com/openai/openai-node",
"default": {
"apiKey": ""
}
},
"providers.fal": {
"type": "object",
"description": "The config for the fal provider.\n@default {\"apiKey\":\"\"}",
"default": {
"apiKey": ""
}
},
"providers.gemini": {
"type": "object",
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\"}",
"default": {
"apiKey": ""
}
},
"providers.perplexity": {
"type": "object",
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\"}",
"default": {
"apiKey": ""
}
},
"unsplash": {
"type": "object",
"description": "The config for the unsplash key.\n@default {\"key\":\"\"}",
"default": {
"key": ""
}
},
"storage": {
"type": "object",
"description": "The config for the storage provider.\n@default {\"provider\":\"fs\",\"bucket\":\"copilot\",\"config\":{\"path\":\"~/.affine/storage\"}}",
"oneOf": [
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"fs"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"aws-s3"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"cloudflare-r2"
]
},
"bucket": {
"type": "string"
},
"config": {
"type": "object",
"description": "The config for the s3 compatible storage provider. directly passed to aws-sdk client.\n@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
"properties": {
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
"properties": {
"accessKeyId": {
"type": "string"
},
"secretAccessKey": {
"type": "string"
}
}
},
"accountId": {
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
}
}
}
}
}
],
"default": {
"provider": "fs",
"bucket": "copilot",
"config": {
"path": "~/.affine/storage"
}
}
}
}
},
"customerIo": {
"type": "object",
"description": "Configuration for customerIo module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable customer.io integration\n@default false",
"default": false
},
"token": {
"type": "string",
"description": "Customer.io token\n@default \"\"",
"default": ""
}
}
},
"oauth": {
"type": "object",
"description": "Configuration for oauth module",
"properties": {
"providers.google": {
"type": "object",
"description": "Google OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://developers.google.com/identity/protocols/oauth2/web-server",
"properties": {
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"args": {
"type": "object"
}
},
"default": {
"clientId": "",
"clientSecret": ""
}
},
"providers.github": {
"type": "object",
"description": "GitHub OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://docs.github.com/en/apps/oauth-apps",
"properties": {
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"args": {
"type": "object"
}
},
"default": {
"clientId": "",
"clientSecret": ""
}
},
"providers.oidc": {
"type": "object",
"description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}",
"default": {
"clientId": "",
"clientSecret": "",
"issuer": "",
"args": {}
}
}
}
},
"payment": {
"type": "object",
"description": "Configuration for payment module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether enable payment plugin\n@default false",
"default": false
},
"showLifetimePrice": {
"type": "boolean",
"description": "Whether enable lifetime price and allow user to pay for it.\n@default true",
"default": true
},
"apiKey": {
"type": "string",
"description": "Stripe API key to enable payment service.\n@default \"\"\n@environment `STRIPE_API_KEY`",
"default": ""
},
"webhookKey": {
"type": "string",
"description": "Stripe webhook key to enable payment service.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
"default": ""
},
"stripe": {
"type": "object",
"description": "Stripe API keys\n@default {}\n@link https://docs.stripe.com/api",
"default": {}
}
}
},
"worker": {
"type": "object",
"description": "Configuration for worker module",
"properties": {
"allowedOrigin": {
"type": "array",
"description": "Allowed origin\n@default [\"localhost\",\"127.0.0.1\"]",
"default": [
"localhost",
"127.0.0.1"
]
}
}
}
}
}

View File

@@ -10,29 +10,10 @@ const {
DATABASE_USERNAME, DATABASE_USERNAME,
DATABASE_PASSWORD, DATABASE_PASSWORD,
DATABASE_NAME, DATABASE_NAME,
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
CAPTCHA_TURNSTILE_SECRET,
METRICS_CUSTOMER_IO_TOKEN,
COPILOT_OPENAI_API_KEY,
COPILOT_FAL_API_KEY,
COPILOT_GOOGLE_API_KEY,
COPILOT_PERPLEXITY_API_KEY,
COPILOT_UNSPLASH_API_KEY,
MAILER_SENDER,
MAILER_USER,
MAILER_PASSWORD,
AFFINE_GOOGLE_CLIENT_ID,
AFFINE_GOOGLE_CLIENT_SECRET,
CLOUD_SQL_IAM_ACCOUNT, CLOUD_SQL_IAM_ACCOUNT,
APP_IAM_ACCOUNT, APP_IAM_ACCOUNT,
GCLOUD_CONNECTION_NAME,
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
REDIS_HOST, REDIS_HOST,
REDIS_PASSWORD, REDIS_PASSWORD,
STRIPE_API_KEY,
STRIPE_WEBHOOK_KEY,
STATIC_IP_NAME, STATIC_IP_NAME,
} = process.env; } = process.env;
@@ -89,13 +70,11 @@ const createHelmCommand = ({ isDryRun }) => {
const redisAndPostgres = const redisAndPostgres =
isProduction || isBeta || isInternal isProduction || isBeta || isInternal
? [ ? [
`--set-string global.database.url=${DATABASE_URL}`, `--set cloud-sql-proxy.enabled=true`,
`--set-string global.database.host=${DATABASE_URL}`,
`--set-string global.database.user=${DATABASE_USERNAME}`, `--set-string global.database.user=${DATABASE_USERNAME}`,
`--set-string global.database.password=${DATABASE_PASSWORD}`, `--set-string global.database.password=${DATABASE_PASSWORD}`,
`--set-string global.database.name=${DATABASE_NAME}`, `--set-string global.database.name=${DATABASE_NAME}`,
`--set global.database.gcloud.enabled=true`,
`--set-string global.database.gcloud.connectionName="${GCLOUD_CONNECTION_NAME}"`,
`--set-string global.database.gcloud.cloudSqlInternal="${GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT}"`,
`--set-string global.redis.host="${REDIS_HOST}"`, `--set-string global.redis.host="${REDIS_HOST}"`,
`--set-string global.redis.password="${REDIS_PASSWORD}"`, `--set-string global.redis.password="${REDIS_PASSWORD}"`,
] ]
@@ -141,14 +120,12 @@ const createHelmCommand = ({ isDryRun }) => {
const deployCommand = [ const deployCommand = [
`helm upgrade --install affine .github/helm/affine`, `helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`, `--namespace ${namespace}`,
`--set-string global.deployment.type="affine"`,
`--set-string global.deployment.platform="gcp"`,
`--set-string global.app.buildType="${buildType}"`, `--set-string global.app.buildType="${buildType}"`,
`--set global.ingress.enabled=true`, `--set global.ingress.enabled=true`,
`--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`, `--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`,
`--set-string global.ingress.host="${host}"`, `--set-string global.ingress.host="${host}"`,
`--set global.objectStorage.r2.enabled=true`,
`--set-string global.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
`--set-string global.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
`--set-string global.objectStorage.r2.secretAccessKey="${R2_SECRET_ACCESS_KEY}"`,
`--set-string global.version="${APP_VERSION}"`, `--set-string global.version="${APP_VERSION}"`,
...redisAndPostgres, ...redisAndPostgres,
`--set web.replicaCount=${replica.web}`, `--set web.replicaCount=${replica.web}`,
@@ -156,27 +133,6 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${replica.graphql}`, `--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`, `--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.host=${host}`, `--set graphql.app.host=${host}`,
`--set graphql.app.captcha.enabled=true`,
`--set-string graphql.app.captcha.turnstile.secret="${CAPTCHA_TURNSTILE_SECRET}"`,
`--set graphql.app.copilot.enabled=true`,
`--set-string graphql.app.copilot.openai.key="${COPILOT_OPENAI_API_KEY}"`,
`--set-string graphql.app.copilot.fal.key="${COPILOT_FAL_API_KEY}"`,
`--set-string graphql.app.copilot.google.key="${COPILOT_GOOGLE_API_KEY}"`,
`--set-string graphql.app.copilot.perplexity.key="${COPILOT_PERPLEXITY_API_KEY}"`,
`--set-string graphql.app.copilot.unsplash.key="${COPILOT_UNSPLASH_API_KEY}"`,
`--set-string graphql.app.mailer.sender="${MAILER_SENDER}"`,
`--set-string graphql.app.mailer.user="${MAILER_USER}"`,
`--set-string graphql.app.mailer.password="${MAILER_PASSWORD}"`,
`--set-string graphql.app.oauth.google.enabled=true`,
`--set-string graphql.app.oauth.google.clientId="${AFFINE_GOOGLE_CLIENT_ID}"`,
`--set-string graphql.app.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`,
`--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`,
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`,
`--set graphql.app.metrics.enabled=true`,
`--set-string graphql.app.metrics.customerIo.token="${METRICS_CUSTOMER_IO_TOKEN}"`,
`--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`,
`--set graphql.app.features.earlyAccessPreview=false`,
`--set graphql.app.features.syncClientVersionCheck=true`,
`--set sync.replicaCount=${replica.sync}`, `--set sync.replicaCount=${replica.sync}`,
`--set-string sync.image.tag="${imageTag}"`, `--set-string sync.image.tag="${imageTag}"`,
`--set-string renderer.image.tag="${imageTag}"`, `--set-string renderer.image.tag="${imageTag}"`,
@@ -184,11 +140,6 @@ const createHelmCommand = ({ isDryRun }) => {
`--set renderer.replicaCount=${replica.renderer}`, `--set renderer.replicaCount=${replica.renderer}`,
`--set-string doc.image.tag="${imageTag}"`, `--set-string doc.image.tag="${imageTag}"`,
`--set doc.app.host=${host}`, `--set doc.app.host=${host}`,
`--set doc.app.copilot.enabled=true`,
`--set-string doc.app.copilot.openai.key="${COPILOT_OPENAI_API_KEY}"`,
`--set-string doc.app.copilot.fal.key="${COPILOT_FAL_API_KEY}"`,
`--set-string doc.app.copilot.perplexity.key="${COPILOT_PERPLEXITY_API_KEY}"`,
`--set-string doc.app.copilot.unsplash.key="${COPILOT_UNSPLASH_API_KEY}"`,
`--set doc.replicaCount=${replica.doc}`, `--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations, ...serviceAnnotations,
...resources, ...resources,

View File

@@ -40,7 +40,9 @@ spec:
- name: NO_COLOR - name: NO_COLOR
value: "1" value: "1"
- name: DEPLOYMENT_TYPE - name: DEPLOYMENT_TYPE
value: "affine" value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR - name: SERVER_FLAVOR
value: "doc" value: "doc"
- name: AFFINE_ENV - name: AFFINE_ENV
@@ -75,50 +77,6 @@ spec:
value: "{{ .Values.app.host }}" value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS - name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}" value: "{{ .Values.app.https }}"
{{ if .Values.global.objectStorage.r2.enabled }}
- name: R2_OBJECT_STORAGE_ACCOUNT_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accountId
- name: R2_OBJECT_STORAGE_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accessKeyId
- name: R2_OBJECT_STORAGE_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: secretAccessKey
{{ end }}
{{ if .Values.app.copilot.enabled }}
- name: COPILOT_OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: openaiSecret
- name: COPILOT_FAL_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: falSecret
- name: COPILOT_GOOGLE_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: googleSecret
- name: COPILOT_PERPLEXITY_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: perplexitySecret
- name: COPILOT_UNSPLASH_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: unsplashSecret
{{ end }}
ports: ports:
- name: http - name: http
containerPort: {{ .Values.global.docService.port }} containerPort: {{ .Values.global.docService.port }}

View File

@@ -1,4 +1,4 @@
{{- if .Values.global.database.gcloud.enabled -}} {{- if .Values.enabled -}}
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -42,7 +42,7 @@ spec:
- "0.0.0.0" - "0.0.0.0"
- "--structured-logs" - "--structured-logs"
- "--auto-iam-authn" - "--auto-iam-authn"
- "{{ .Values.global.database.gcloud.connectionName }}" - "{{ .Values.database.connectionName }}"
env: env:
# Enable HTTP healthchecks on port 9801. This enables /liveness, # Enable HTTP healthchecks on port 9801. This enables /liveness,
# /readiness and /startup health check endpoints. Allow connections # /readiness and /startup health check endpoints. Allow connections
@@ -56,7 +56,7 @@ spec:
value: 0.0.0.0 value: 0.0.0.0
ports: ports:
- name: cloud-sql-proxy - name: cloud-sql-proxy
containerPort: {{ .Values.global.database.gcloud.proxyPort }} containerPort: {{ .Values.service.port }}
protocol: TCP protocol: TCP
- containerPort: 9801 - containerPort: 9801
protocol: TCP protocol: TCP

View File

@@ -1,4 +1,5 @@
replicaCount: 3 replicaCount: 3
enabled: false
image: image:
# the tag is defined as chart appVersion. # the tag is defined as chart appVersion.

View File

@@ -1,9 +0,0 @@
{{- if .Values.app.captcha.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.captcha.secretName }}"
type: Opaque
data:
turnstileSecret: {{ .Values.app.captcha.turnstile.secret | b64enc }}
{{- end }}

View File

@@ -1,13 +0,0 @@
{{- if .Values.app.copilot.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.copilot.secretName }}"
type: Opaque
data:
openaiSecret: {{ .Values.app.copilot.openai.key | b64enc }}
falSecret: {{ .Values.app.copilot.fal.key | b64enc }}
googleSecret: {{ .Values.app.copilot.google.key | b64enc }}
perplexitySecret: {{ .Values.app.copilot.perplexity.key | b64enc }}
unsplashSecret: {{ .Values.app.copilot.unsplash.key | b64enc }}
{{- end }}

View File

@@ -40,7 +40,9 @@ spec:
- name: NO_COLOR - name: NO_COLOR
value: "1" value: "1"
- name: DEPLOYMENT_TYPE - name: DEPLOYMENT_TYPE
value: "affine" value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR - name: SERVER_FLAVOR
value: "graphql" value: "graphql"
- name: AFFINE_ENV - name: AFFINE_ENV
@@ -52,8 +54,6 @@ spec:
key: postgres-password key: postgres-password
- name: DATABASE_URL - name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }} value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST - name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}" value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT - name: REDIS_SERVER_PORT
@@ -75,135 +75,8 @@ spec:
value: "{{ .Values.app.host }}" value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS - name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}" value: "{{ .Values.app.https }}"
- name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.global.objectStorage.r2.enabled }}"
- name: FEATURES_SYNC_CLIENT_VERSION_CHECK
value: "{{ .Values.app.features.syncClientVersionCheck }}"
- name: MAILER_HOST
valueFrom:
secretKeyRef:
name: "{{ .Values.app.mailer.secretName }}"
key: host
- name: MAILER_PORT
valueFrom:
secretKeyRef:
name: "{{ .Values.app.mailer.secretName }}"
key: port
- name: MAILER_USER
valueFrom:
secretKeyRef:
name: "{{ .Values.app.mailer.secretName }}"
key: user
- name: MAILER_PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Values.app.mailer.secretName }}"
key: password
- name: MAILER_SENDER
valueFrom:
secretKeyRef:
name: "{{ .Values.app.mailer.secretName }}"
key: sender
- name: STRIPE_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.payment.stripe.secretName }}"
key: stripeAPIKey
- name: STRIPE_WEBHOOK_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.payment.stripe.secretName }}"
key: stripeWebhookKey
- name: DOC_SERVICE_ENDPOINT - name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}" value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
{{ if .Values.app.experimental.enableJwstCodec }}
- name: DOC_MERGE_USE_JWST_CODEC
value: "true"
{{ end }}
{{ if .Values.global.objectStorage.r2.enabled }}
- name: R2_OBJECT_STORAGE_ACCOUNT_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accountId
- name: R2_OBJECT_STORAGE_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accessKeyId
- name: R2_OBJECT_STORAGE_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: secretAccessKey
{{ end }}
{{ if .Values.app.captcha.enabled }}
- name: CAPTCHA_TURNSTILE_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Values.app.captcha.secretName }}"
key: turnstileSecret
{{ end }}
{{ if .Values.app.copilot.enabled }}
- name: COPILOT_OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: openaiSecret
- name: COPILOT_FAL_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: falSecret
- name: COPILOT_GOOGLE_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: googleSecret
- name: COPILOT_PERPLEXITY_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: perplexitySecret
- name: COPILOT_UNSPLASH_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: unsplashSecret
{{ end }}
{{ if .Values.app.oauth.google.enabled }}
- name: OAUTH_GOOGLE_ENABLED
value: "true"
- name: OAUTH_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.google.secretName }}"
key: clientId
- name: OAUTH_GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.google.secretName }}"
key: clientSecret
{{ end }}
{{ if .Values.app.oauth.github.enabled }}
- name: OAUTH_GITHUB_CLIENT_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.github.secretName }}"
key: clientId
- name: OAUTH_GITHUB_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.github.secretName }}"
key: clientSecret
{{ end }}
{{ if .Values.app.metrics.enabled }}
- name: METRICS_CUSTOMER_IO_TOKEN
valueFrom:
secretKeyRef:
name: "{{ .Values.app.metrics.secretName }}"
key: customerIoSecret
{{ end }}
ports: ports:
- name: http - name: http
containerPort: {{ .Values.service.port }} containerPort: {{ .Values.service.port }}

View File

@@ -1,13 +0,0 @@
{{- if .Values.app.mailer.secretName -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.mailer.secretName }}"
type: Opaque
data:
host: "{{ .Values.app.mailer.host | b64enc }}"
port: "{{ .Values.app.mailer.port | b64enc }}"
user: "{{ .Values.app.mailer.user | b64enc }}"
password: "{{ .Values.app.mailer.password | b64enc }}"
sender: "{{ .Values.app.mailer.sender | b64enc }}"
{{- end }}

View File

@@ -1,9 +0,0 @@
{{- if .Values.app.metrics.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.metrics.secretName }}"
type: Opaque
data:
customerIoSecret: {{ .Values.app.metrics.customerIo.token | b64enc }}
{{- end }}

View File

@@ -23,37 +23,27 @@ spec:
- name: AFFINE_ENV - name: AFFINE_ENV
value: "{{ .Release.Namespace }}" value: "{{ .Release.Namespace }}"
- name: DEPLOYMENT_TYPE - name: DEPLOYMENT_TYPE
value: "affine" value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: DATABASE_PASSWORD - name: DATABASE_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: pg-postgresql name: pg-postgresql
key: postgres-password key: postgres-password
{{ if not .Values.global.database.gcloud.enabled }}
- name: DATABASE_URL - name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }} value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
{{ end }} - name: REDIS_SERVER_HOST
{{ if .Values.global.database.gcloud.enabled }} value: "{{ .Values.global.redis.host }}"
- name: DATABASE_URL - name: REDIS_SERVER_PORT
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.gcloud.cloudSqlInternal }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }} value: "{{ .Values.global.redis.port }}"
{{ end }} - name: REDIS_SERVER_USER
{{ if .Values.global.objectStorage.r2.enabled }} value: "{{ .Values.global.redis.username }}"
- name: R2_OBJECT_STORAGE_ACCOUNT_ID - name: REDIS_SERVER_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}" name: redis
key: accountId key: redis-password
- name: R2_OBJECT_STORAGE_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accessKeyId
- name: R2_OBJECT_STORAGE_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: secretAccessKey
{{ end }}
resources: resources:
requests: requests:
cpu: '100m' cpu: '100m'

View File

@@ -1,21 +0,0 @@
{{- if .Values.app.oauth.google.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.google.secretName }}"
type: Opaque
data:
clientId: "{{ .Values.app.oauth.google.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.google.clientSecret | b64enc }}"
{{- end }}
---
{{- if .Values.app.oauth.github.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.github.secretName }}"
type: Opaque
data:
clientId: "{{ .Values.app.oauth.github.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.github.clientSecret | b64enc }}"
{{- end }}

View File

@@ -1,8 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.payment.stripe.secretName }}"
type: Opaque
data:
stripeAPIKey: "{{ .Values.app.payment.stripe.apiKey | b64enc }}"
stripeWebhookKey: "{{ .Values.app.payment.stripe.webhookKey | b64enc }}"

View File

@@ -10,54 +10,11 @@ fullnameOverride: ''
# map to NODE_ENV environment variable # map to NODE_ENV environment variable
env: 'production' env: 'production'
app: app:
experimental:
enableJwstCodec: true
# AFFINE_SERVER_SUB_PATH # AFFINE_SERVER_SUB_PATH
path: '' path: ''
# AFFINE_SERVER_HOST # AFFINE_SERVER_HOST
host: '0.0.0.0' host: '0.0.0.0'
https: true https: true
captcha:
enabled: false
secretName: captcha
turnstile:
secret: ''
copilot:
enabled: false
secretName: copilot
openai:
key: ''
oauth:
google:
enabled: false
secretName: oauth-google
clientId: ''
clientSecret: ''
github:
enabled: false
secretName: oauth-github
clientId: ''
clientSecret: ''
mailer:
secretName: 'mailer'
host: 'smtp.gmail.com'
port: '465'
user: ''
password: ''
sender: 'noreply@toeverything.info'
metrics:
enabled: false
secretName: 'metrics'
customerIo:
token: ''
payment:
stripe:
secretName: 'stripe'
apiKey: ''
webhookKey: ''
features:
earlyAccessPreview: false
syncClientVersionCheck: false
serviceAccount: serviceAccount:
create: true create: true

View File

@@ -40,7 +40,9 @@ spec:
- name: NO_COLOR - name: NO_COLOR
value: "1" value: "1"
- name: DEPLOYMENT_TYPE - name: DEPLOYMENT_TYPE
value: "affine" value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR - name: SERVER_FLAVOR
value: "renderer" value: "renderer"
- name: AFFINE_ENV - name: AFFINE_ENV
@@ -75,25 +77,6 @@ spec:
value: "{{ .Values.app.host }}" value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS - name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}" value: "{{ .Values.app.https }}"
- name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.global.objectStorage.r2.enabled }}"
{{ if .Values.global.objectStorage.r2.enabled }}
- name: R2_OBJECT_STORAGE_ACCOUNT_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accountId
- name: R2_OBJECT_STORAGE_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: accessKeyId
- name: R2_OBJECT_STORAGE_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
key: secretAccessKey
{{ end }}
- name: DOC_SERVICE_ENDPOINT - name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}" value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports: ports:

View File

@@ -42,7 +42,9 @@ spec:
- name: NO_COLOR - name: NO_COLOR
value: "1" value: "1"
- name: DEPLOYMENT_TYPE - name: DEPLOYMENT_TYPE
value: "affine" value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR - name: SERVER_FLAVOR
value: "sync" value: "sync"
- name: AFFINE_ENV - name: AFFINE_ENV
@@ -54,8 +56,6 @@ spec:
key: postgres-password key: postgres-password
- name: DATABASE_URL - name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }} value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST - name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}" value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT - name: REDIS_SERVER_PORT

View File

@@ -1,11 +0,0 @@
{{- if .Values.global.objectStorage.r2.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.global.objectStorage.r2.secretName }}"
type: Opaque
data:
accountId: {{ .Values.global.objectStorage.r2.accountId | b64enc }}
accessKeyId: {{ .Values.global.objectStorage.r2.accessKeyId | b64enc }}
secretAccessKey: {{ .Values.global.objectStorage.r2.secretAccessKey | b64enc }}
{{- end }}

View File

@@ -11,18 +11,10 @@ global:
privateKey: '' privateKey: ''
database: database:
user: 'postgres' user: 'postgres'
url: 'pg-postgresql' host: 'pg-postgresql'
port: '5432' port: '5432'
name: 'affine' name: 'affine'
password: '' password: ''
gcloud:
enabled: false
# use for migration
cloudSqlInternal: ''
connectionName: ''
serviceAccount: ''
cloudProxyReplicas: 3
proxyPort: '5432'
redis: redis:
enabled: true enabled: true
host: 'redis-master' host: 'redis-master'
@@ -30,18 +22,13 @@ global:
username: '' username: ''
password: '' password: ''
database: 0 database: 0
objectStorage:
r2:
enabled: false
secretName: r2
accountId: ''
accessKeyId: ''
secretAccessKey: ''
gke:
enabled: true
docService: docService:
name: 'affine-doc' name: 'affine-doc'
port: 3020 port: 3020
deployment:
# change to 'selfhosted' and 'unknown' if this chart is ready to be used for selfhosted deployment
type: 'affine'
platform: 'gcp'
graphql: graphql:
service: service:

View File

@@ -37,3 +37,4 @@ packages/frontend/apps/android/App/**
packages/frontend/apps/ios/App/** packages/frontend/apps/ios/App/**
tests/blocksuite/snapshots tests/blocksuite/snapshots
blocksuite/docs/api/** blocksuite/docs/api/**
packages/frontend/admin/src/config.json

View File

@@ -37,7 +37,8 @@
"packages/frontend/apps/android/App/**", "packages/frontend/apps/android/App/**",
"packages/frontend/apps/ios/App/**", "packages/frontend/apps/ios/App/**",
"tests/blocksuite/snapshots", "tests/blocksuite/snapshots",
"blocksuite/docs/api/**" "blocksuite/docs/api/**",
"packages/frontend/admin/src/config.json"
], ],
"rules": { "rules": {
"no-await-in-loop": "allow", "no-await-in-loop": "allow",

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "app_configs" (
"id" VARCHAR NOT NULL,
"value" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"last_updated_by" VARCHAR,
CONSTRAINT "app_configs_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "app_configs" ADD CONSTRAINT "app_configs_last_updated_by_fkey" FOREIGN KEY ("last_updated_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -8,7 +8,7 @@
"run-test": "./scripts/run-test.ts" "run-test": "./scripts/run-test.ts"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc -b",
"dev": "nodemon ./src/index.ts", "dev": "nodemon ./src/index.ts",
"dev:mail": "email dev -d src/mails", "dev:mail": "email dev -d src/mails",
"test": "ava --concurrency 1 --serial", "test": "ava --concurrency 1 --serial",
@@ -20,6 +20,7 @@
"data-migration": "cross-env NODE_ENV=development r ./src/data/index.ts", "data-migration": "cross-env NODE_ENV=development r ./src/data/index.ts",
"init": "yarn prisma migrate dev && yarn data-migration run", "init": "yarn prisma migrate dev && yarn data-migration run",
"seed": "r ./src/seed/index.ts", "seed": "r ./src/seed/index.ts",
"genconfig": "r ./scripts/genconfig.ts",
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run", "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
@@ -139,7 +140,8 @@
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"react-email": "3.0.7", "react-email": "3.0.7",
"sinon": "^19.0.2", "sinon": "^19.0.2",
"supertest": "^7.0.0" "supertest": "^7.0.0",
"why-is-node-running": "^3.2.2"
}, },
"nodemonConfig": { "nodemonConfig": {
"exec": "node", "exec": "node",

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,9 @@ model User {
connectedAccounts ConnectedAccount[] connectedAccounts ConnectedAccount[]
sessions UserSession[] sessions UserSession[]
aiSessions AiSession[] aiSessions AiSession[]
updatedRuntimeConfigs RuntimeConfig[] /// @deprecated
deprecatedAppRuntimeSettings DeprecatedAppRuntimeSettings[]
appConfigs AppConfig[]
userSnapshots UserSnapshot[] userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot") createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot") updatedSnapshot Snapshot[] @relation("updatedSnapshot")
@@ -527,7 +529,8 @@ enum RuntimeConfigType {
Array Array
} }
model RuntimeConfig { /// @deprecated use AppConfig instead
model DeprecatedAppRuntimeSettings {
id String @id @db.VarChar id String @id @db.VarChar
type RuntimeConfigType type RuntimeConfigType
module String @db.VarChar module String @db.VarChar
@@ -544,6 +547,18 @@ model RuntimeConfig {
@@map("app_runtime_settings") @@map("app_runtime_settings")
} }
model AppConfig {
id String @id @db.VarChar
value Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id], onDelete: SetNull)
@@map("app_configs")
}
model DeprecatedUserSubscription { model DeprecatedUserSubscription {
id Int @id @default(autoincrement()) @db.Integer id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar userId String @map("user_id") @db.VarChar

View File

@@ -0,0 +1,101 @@
/* eslint-disable */
import '../src/prelude';
import '../src/app.module';
import fs from 'node:fs';
import { ProjectRoot } from '@affine-tools/utils/path';
import { Package } from '@affine-tools/utils/workspace';
import { getDescriptors, ConfigDescriptor } from '../src/base/config/register';
import { pick } from 'lodash-es';
interface PropertySchema {
description: string;
type?: 'array' | 'boolean' | 'integer' | 'number' | 'object' | 'string';
default?: any;
}
function convertDescriptorToSchemaProperty(descriptor: ConfigDescriptor<any>) {
const property: PropertySchema = {
...descriptor.schema,
description:
descriptor.schema.description +
`\n@default ${JSON.stringify(descriptor.default)}` +
(descriptor.env ? `\n@environment \`${descriptor.env[0]}\`` : '') +
(descriptor.link ? `\n@link ${descriptor.link}` : ''),
default: descriptor.default,
};
return property;
}
function generateJsonSchema(outputPath: string) {
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'AFFiNE Application Configuration',
type: 'object',
properties: {},
};
getDescriptors().forEach(({ module, descriptors }) => {
schema.properties[module] = {
type: 'object',
description: `Configuration for ${module} module`,
properties: {},
};
descriptors.forEach(({ key, descriptor }) => {
schema.properties[module].properties[key] =
convertDescriptorToSchemaProperty(descriptor);
});
});
fs.writeFileSync(outputPath, JSON.stringify(schema, null, 2));
console.log(`Config schema generated at: ${outputPath}`);
}
function generateAdminConfigJson(outputPath: string) {
const config = {};
getDescriptors().forEach(({ module, descriptors }) => {
const modulizedConfig = {};
config[module] = modulizedConfig;
descriptors.forEach(({ key, descriptor }) => {
let type: string;
switch (descriptor.schema?.type) {
case 'number':
type = 'Number';
break;
case 'boolean':
type = 'Boolean';
break;
case 'array':
type = 'Array';
break;
case 'object':
type = 'Object';
break;
default:
type = 'String';
}
modulizedConfig[key] = {
type,
desc: descriptor.desc,
link: descriptor.link,
env: descriptor.env?.[0],
};
});
});
fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
}
function main() {
generateJsonSchema(
ProjectRoot.join('.docker', 'selfhost', 'schema.json').toString()
);
generateAdminConfigJson(
new Package('@affine/admin').join('src/config.json').toString()
);
}
main();

View File

@@ -1,17 +1,10 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { generateKeyPairSync } from 'node:crypto'; import { generateKeyPairSync } from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
const SELF_HOST_CONFIG_DIR = '/root/.affine/config'; const SELF_HOST_CONFIG_DIR = `${homedir()}/.affine/config`;
function generateConfigFile() {
const content = fs.readFileSync('./dist/config/affine.js', 'utf-8');
return content.replace(
/(^\/\/#.*$)|(^\/\/\s+TODO.*$)|("use\sstrict";?)|(^.*lint-disable.*$)/gm,
''
);
}
function generatePrivateKey() { function generatePrivateKey() {
const key = generateKeyPairSync('ec', { const key = generateKeyPairSync('ec', {
@@ -31,15 +24,12 @@ function generatePrivateKey() {
/** /**
* @type {Array<{ to: string; generator: () => string }>} * @type {Array<{ to: string; generator: () => string }>}
*/ */
const configFiles = [ const files = [{ to: 'private.key', generator: generatePrivateKey }];
{ to: 'affine.js', generator: generateConfigFile },
{ to: 'private.key', generator: generatePrivateKey },
];
function prepare() { function prepare() {
fs.mkdirSync(SELF_HOST_CONFIG_DIR, { recursive: true }); fs.mkdirSync(SELF_HOST_CONFIG_DIR, { recursive: true });
for (const { to, generator } of configFiles) { for (const { to, generator } of files) {
const targetFilePath = path.join(SELF_HOST_CONFIG_DIR, to); const targetFilePath = path.join(SELF_HOST_CONFIG_DIR, to);
if (!fs.existsSync(targetFilePath)) { if (!fs.existsSync(targetFilePath)) {
console.log(`creating config file [${targetFilePath}].`); console.log(`creating config file [${targetFilePath}].`);

View File

@@ -1,36 +0,0 @@
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
app: INestApplication;
}>;
test.before('start app', async t => {
// @ts-expect-error override
AFFiNE.flavor = {
type: 'doc',
doc: true,
} as typeof AFFiNE.flavor;
const app = await createTestingApp({
imports: [buildAppModule()],
});
t.context.app = app;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should init app', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/info')
.expect(200);
t.is(res.body.flavor, 'doc');
});

View File

@@ -1,137 +0,0 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import type { TestFn } from 'ava';
import ava from 'ava';
import GraphQLUpload, {
type FileUpload,
} from 'graphql-upload/GraphQLUpload.mjs';
import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { Public } from '../../core/auth';
import { createTestingApp, TestingApp } from '../utils';
const gql = '/graphql';
const test = ava as TestFn<{
app: TestingApp;
}>;
@Resolver(() => String)
class TestResolver {
@Public()
@Mutation(() => Number)
async upload(
@Args({ name: 'body', type: () => GraphQLUpload })
body: FileUpload
): Promise<number> {
const size = await new Promise<number>((resolve, reject) => {
const stream = body.createReadStream();
let size = 0;
stream.on('data', chunk => (size += chunk.length));
stream.on('error', reject);
stream.on('end', () => resolve(size));
});
return size;
}
}
test.before('start app', async t => {
// @ts-expect-error override
AFFiNE.flavor = {
type: 'graphql',
graphql: true,
} as typeof AFFiNE.flavor;
const app = await createTestingApp({
imports: [buildAppModule()],
providers: [TestResolver],
});
t.context.app = app;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should init app', async t => {
await request(t.context.app.getHttpServer())
.post(gql)
.send({
query: `
query {
error
}
`,
})
.expect(400);
const response = await request(t.context.app.getHttpServer())
.post(gql)
.send({
query: `query {
serverConfig {
name
version
type
features
}
}`,
})
.expect(200);
const config = response.body.data.serverConfig;
t.is(config.type, 'Affine');
t.true(Array.isArray(config.features));
// make sure the request id is set
t.truthy(response.headers['x-request-id']);
});
test('should return 404 for unknown path', async t => {
await request(t.context.app.getHttpServer()).get('/unknown').expect(404);
t.pass();
});
test('should be able to call apis', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/info')
.expect(200);
t.is(res.body.flavor, 'graphql');
// make sure the request id is set
t.truthy(res.headers['x-request-id']);
});
test('should not throw internal error when graphql call with invalid params', async t => {
await t.throwsAsync(t.context.app.gql(`query { workspace("1") }`), {
message: /Failed to execute gql: query { workspace\("1"\) \}, status: 400/,
});
});
test('should can send maximum size of body', async t => {
const { app } = t.context;
const body = Buffer.from('a'.repeat(10 * 1024 * 1024 - 1));
const res = await app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'upload',
query: `mutation upload($body: Upload!) { upload(body: $body) }`,
variables: { body: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.body'] }))
.attach(
'0',
body,
`body-${Math.random().toString(16).substring(2, 10)}.data`
)
.expect(200);
t.is(Number(res.body.data.upload), body.length);
});

View File

@@ -1,36 +0,0 @@
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
app: INestApplication;
}>;
test.before('start app', async t => {
// @ts-expect-error override
AFFiNE.flavor = {
type: 'renderer',
renderer: true,
} as typeof AFFiNE.flavor;
const app = await createTestingApp({
imports: [buildAppModule()],
});
t.context.app = app;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should init app', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/info')
.expect(200);
t.is(res.body.flavor, 'renderer');
});

View File

@@ -8,7 +8,6 @@ import ava from 'ava';
import request from 'supertest'; import request from 'supertest';
import { buildAppModule } from '../../app.module'; import { buildAppModule } from '../../app.module';
import { Config } from '../../base';
import { Public } from '../../core/auth'; import { Public } from '../../core/auth';
import { ServerService } from '../../core/config'; import { ServerService } from '../../core/config';
import { createTestingApp, type TestingApp } from '../utils'; import { createTestingApp, type TestingApp } from '../utils';
@@ -49,18 +48,16 @@ export class TestResolver {
test.before('init selfhost server', async t => { test.before('init selfhost server', async t => {
// @ts-expect-error override // @ts-expect-error override
AFFiNE.isSelfhosted = true; globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
AFFiNE.flavor.renderer = true;
const app = await createTestingApp({ const app = await createTestingApp({
imports: [buildAppModule()], imports: [buildAppModule(globalThis.env)],
controllers: [TestResolver], controllers: [TestResolver],
}); });
t.context.app = app; t.context.app = app;
t.context.db = t.context.app.get(PrismaClient); t.context.db = t.context.app.get(PrismaClient);
const config = app.get(Config);
const staticPath = path.join(config.projectRoot, 'static'); const staticPath = path.join(env.projectRoot, 'static');
initTestStaticFiles(staticPath); initTestStaticFiles(staticPath);
}); });

View File

@@ -1,36 +0,0 @@
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
app: INestApplication;
}>;
test.before('start app', async t => {
// @ts-expect-error override
AFFiNE.flavor = {
type: 'sync',
sync: true,
} as typeof AFFiNE.flavor;
const app = await createTestingApp({
imports: [buildAppModule()],
});
t.context.app = app;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should init app', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/info')
.expect(200);
t.is(res.body.flavor, 'sync');
});

View File

@@ -5,10 +5,7 @@ import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { AuthModule } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
import { UserModule } from '../../core/user';
import { import {
createTestingApp, createTestingApp,
currentUser, currentUser,
@@ -23,9 +20,7 @@ const test = ava as TestFn<{
}>; }>;
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ const app = await createTestingApp();
imports: [FeatureModule, UserModule, AuthModule],
});
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);

View File

@@ -1,39 +0,0 @@
import { TestingModule } from '@nestjs/testing';
import test from 'ava';
import { Config, ConfigModule } from '../base/config';
import { createTestingModule } from './utils';
let config: Config;
let module: TestingModule;
test.beforeEach(async () => {
module = await createTestingModule({}, false);
config = module.get(Config);
});
test.afterEach.always(async () => {
await module.close();
});
test('should be able to get config', t => {
t.true(typeof config.server.host === 'string');
t.is(config.projectRoot, process.cwd());
t.is(config.NODE_ENV, 'test');
});
test('should be able to override config', async t => {
const module = await createTestingModule({
imports: [
ConfigModule.forRoot({
server: {
host: 'testing',
},
}),
],
});
const config = module.get(Config);
t.is(config.server.host, 'testing');
await module.close();
});

View File

@@ -7,14 +7,7 @@ import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota'; import { QuotaModule } from '../core/quota';
import { CopilotModule } from '../plugins/copilot'; import { CopilotModule } from '../plugins/copilot';
import { prompts, PromptService } from '../plugins/copilot/prompt'; import { prompts, PromptService } from '../plugins/copilot/prompt';
import { import { CopilotProviderFactory } from '../plugins/copilot/providers';
CopilotProviderService,
FalProvider,
OpenAIProvider,
PerplexityProvider,
registerCopilotProvider,
unregisterCopilotProvider,
} from '../plugins/copilot/providers';
import { import {
CopilotChatTextExecutor, CopilotChatTextExecutor,
CopilotWorkflowService, CopilotWorkflowService,
@@ -32,7 +25,7 @@ type Tester = {
auth: AuthService; auth: AuthService;
module: TestingModule; module: TestingModule;
prompt: PromptService; prompt: PromptService;
provider: CopilotProviderService; factory: CopilotProviderFactory;
workflow: CopilotWorkflowService; workflow: CopilotWorkflowService;
executors: { executors: {
image: CopilotChatImageExecutor; image: CopilotChatImageExecutor;
@@ -67,9 +60,9 @@ const runIfCopilotConfigured = test.macro(
test.serial.before(async t => { test.serial.before(async t => {
const module = await createTestingModule({ const module = await createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
plugins: {
copilot: { copilot: {
providers: {
openai: { openai: {
apiKey: process.env.COPILOT_OPENAI_API_KEY, apiKey: process.env.COPILOT_OPENAI_API_KEY,
}, },
@@ -79,6 +72,9 @@ test.serial.before(async t => {
perplexity: { perplexity: {
apiKey: process.env.COPILOT_PERPLEXITY_API_KEY, apiKey: process.env.COPILOT_PERPLEXITY_API_KEY,
}, },
gemini: {
apiKey: process.env.COPILOT_GOOGLE_API_KEY,
},
}, },
}, },
}), }),
@@ -89,13 +85,13 @@ test.serial.before(async t => {
const auth = module.get(AuthService); const auth = module.get(AuthService);
const prompt = module.get(PromptService); const prompt = module.get(PromptService);
const provider = module.get(CopilotProviderService); const factory = module.get(CopilotProviderFactory);
const workflow = module.get(CopilotWorkflowService); const workflow = module.get(CopilotWorkflowService);
t.context.module = module; t.context.module = module;
t.context.auth = auth; t.context.auth = auth;
t.context.prompt = prompt; t.context.prompt = prompt;
t.context.provider = provider; t.context.factory = factory;
t.context.workflow = workflow; t.context.workflow = workflow;
t.context.executors = { t.context.executors = {
image: module.get(CopilotChatImageExecutor), image: module.get(CopilotChatImageExecutor),
@@ -113,10 +109,6 @@ test.serial.before(async t => {
executors.html.register(); executors.html.register();
executors.json.register(); executors.json.register();
registerCopilotProvider(OpenAIProvider);
registerCopilotProvider(FalProvider);
registerCopilotProvider(PerplexityProvider);
for (const name of await prompt.listNames()) { for (const name of await prompt.listNames()) {
await prompt.delete(name); await prompt.delete(name);
} }
@@ -126,12 +118,6 @@ test.serial.before(async t => {
} }
}); });
test.after(async _ => {
unregisterCopilotProvider(OpenAIProvider.type);
unregisterCopilotProvider(FalProvider.type);
unregisterCopilotProvider(PerplexityProvider.type);
});
test.after(async t => { test.after(async t => {
await t.context.module.close(); await t.context.module.close();
}); });
@@ -523,12 +509,10 @@ for (const { name, promptName, messages, verifier, type } of actions) {
`should be able to run action: ${promptName}${name ? ` - ${name}` : ''}`, `should be able to run action: ${promptName}${name ? ` - ${name}` : ''}`,
runIfCopilotConfigured, runIfCopilotConfigured,
async t => { async t => {
const { provider: providerService, prompt: promptService } = t.context; const { factory, prompt: promptService } = t.context;
const prompt = (await promptService.get(promptName))!; const prompt = (await promptService.get(promptName))!;
t.truthy(prompt, 'should have prompt'); t.truthy(prompt, 'should have prompt');
const provider = (await providerService.getProviderByModel( const provider = (await factory.getProviderByModel(prompt.model))!;
prompt.model
))!;
t.truthy(provider, 'should have provider'); t.truthy(provider, 'should have provider');
await retry(`action: ${promptName}`, t, async t => { await retry(`action: ${promptName}`, t, async t => {
if (type === 'text' && 'generateText' in provider) { if (type === 'text' && 'generateText' in provider) {

View File

@@ -6,12 +6,11 @@ import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { AppModule } from '../app.module';
import { JobQueue } from '../base'; import { JobQueue } from '../base';
import { ConfigModule } from '../base/config'; import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth'; import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc'; import { DocReader } from '../core/doc';
import { WorkspaceModule } from '../core/workspaces';
import { CopilotModule } from '../plugins/copilot';
import { import {
CopilotContextDocJob, CopilotContextDocJob,
CopilotContextService, CopilotContextService,
@@ -19,14 +18,11 @@ import {
import { MockEmbeddingClient } from '../plugins/copilot/context/embedding'; import { MockEmbeddingClient } from '../plugins/copilot/context/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt'; import { prompts, PromptService } from '../plugins/copilot/prompt';
import { import {
CopilotProviderService, CopilotProviderFactory,
FalProvider,
OpenAIProvider, OpenAIProvider,
PerplexityProvider,
registerCopilotProvider,
unregisterCopilotProvider,
} from '../plugins/copilot/providers'; } from '../plugins/copilot/providers';
import { CopilotStorage } from '../plugins/copilot/storage'; import { CopilotStorage } from '../plugins/copilot/storage';
import { MockCopilotProvider } from './mocks';
import { import {
acceptInviteById, acceptInviteById,
createTestingApp, createTestingApp,
@@ -53,7 +49,6 @@ import {
listContextDocAndFiles, listContextDocAndFiles,
matchFiles, matchFiles,
matchWorkspaceDocs, matchWorkspaceDocs,
MockCopilotTestProvider,
sse2array, sse2array,
textToEventStream, textToEventStream,
unsplashSearch, unsplashSearch,
@@ -67,7 +62,7 @@ const test = ava as TestFn<{
context: CopilotContextService; context: CopilotContextService;
jobs: CopilotContextDocJob; jobs: CopilotContextDocJob;
prompt: PromptService; prompt: PromptService;
provider: CopilotProviderService; factory: CopilotProviderFactory;
storage: CopilotStorage; storage: CopilotStorage;
u1: TestUser; u1: TestUser;
}>; }>;
@@ -75,24 +70,19 @@ const test = ava as TestFn<{
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ const app = await createTestingApp({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
plugins: {
copilot: { copilot: {
openai: { providers: {
apiKey: '1', openai: { apiKey: '1' },
fal: {},
perplexity: {},
}, },
fal: { unsplash: {
apiKey: '1', key: process.env.UNSPLASH_ACCESS_KEY || '1',
},
perplexity: {
apiKey: '1',
},
unsplashKey: process.env.UNSPLASH_ACCESS_KEY || '1',
}, },
}, },
}), }),
WorkspaceModule, AppModule,
CopilotModule,
], ],
tapModule: m => { tapModule: m => {
// use real JobQueue for testing // use real JobQueue for testing
@@ -105,6 +95,7 @@ test.before(async t => {
}; };
}, },
}); });
m.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
}, },
}); });
@@ -129,14 +120,9 @@ test.beforeEach(async t => {
Sinon.restore(); Sinon.restore();
const { app, prompt } = t.context; const { app, prompt } = t.context;
await app.initTestingDB(); await app.initTestingDB();
await prompt.onModuleInit(); await prompt.onApplicationBootstrap();
t.context.u1 = await app.signupV1('u1@affine.pro'); t.context.u1 = await app.signupV1('u1@affine.pro');
unregisterCopilotProvider(OpenAIProvider.type);
unregisterCopilotProvider(FalProvider.type);
unregisterCopilotProvider(PerplexityProvider.type);
registerCopilotProvider(MockCopilotTestProvider);
await prompt.set(promptName, 'test', [ await prompt.set(promptName, 'test', [
{ role: 'system', content: 'hello {{word}}' }, { role: 'system', content: 'hello {{word}}' },
]); ]);
@@ -761,13 +747,12 @@ test('should be able to manage context', async t => {
'should throw error if create context with invalid session id' 'should throw error if create context with invalid session id'
); );
const context = createCopilotContext(app, workspaceId, sessionId); const context = await createCopilotContext(app, workspaceId, sessionId);
await t.notThrowsAsync(context, 'should create context with chat session');
const list = await listContext(app, workspaceId, sessionId); const list = await listContext(app, workspaceId, sessionId);
t.deepEqual( t.deepEqual(
list.map(f => ({ id: f.id })), list.map(f => ({ id: f.id })),
[{ id: await context }], [{ id: context }],
'should list context' 'should list context'
); );
} }

View File

@@ -19,18 +19,14 @@ import {
import { MockEmbeddingClient } from '../plugins/copilot/context/embedding'; import { MockEmbeddingClient } from '../plugins/copilot/context/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt'; import { prompts, PromptService } from '../plugins/copilot/prompt';
import { import {
CopilotProviderService, CopilotCapability,
CopilotProviderFactory,
CopilotProviderType,
OpenAIProvider, OpenAIProvider,
registerCopilotProvider,
unregisterCopilotProvider,
} from '../plugins/copilot/providers'; } from '../plugins/copilot/providers';
import { CitationParser } from '../plugins/copilot/providers/perplexity'; import { CitationParser } from '../plugins/copilot/providers/perplexity';
import { ChatSessionService } from '../plugins/copilot/session'; import { ChatSessionService } from '../plugins/copilot/session';
import { CopilotStorage } from '../plugins/copilot/storage'; import { CopilotStorage } from '../plugins/copilot/storage';
import {
CopilotCapability,
CopilotProviderType,
} from '../plugins/copilot/types';
import { import {
CopilotChatTextExecutor, CopilotChatTextExecutor,
CopilotWorkflowService, CopilotWorkflowService,
@@ -50,8 +46,9 @@ import {
} from '../plugins/copilot/workflow/executor'; } from '../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils'; import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph'; import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
import { MockCopilotProvider } from './mocks';
import { createTestingModule, TestingModule } from './utils'; import { createTestingModule, TestingModule } from './utils';
import { MockCopilotTestProvider, WorkflowTestCases } from './utils/copilot'; import { WorkflowTestCases } from './utils/copilot';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
@@ -60,7 +57,7 @@ const test = ava as TestFn<{
event: EventBus; event: EventBus;
context: CopilotContextService; context: CopilotContextService;
prompt: PromptService; prompt: PromptService;
provider: CopilotProviderService; factory: CopilotProviderFactory;
session: ChatSessionService; session: ChatSessionService;
jobs: CopilotContextDocJob; jobs: CopilotContextDocJob;
storage: CopilotStorage; storage: CopilotStorage;
@@ -77,9 +74,9 @@ let userId: string;
test.before(async t => { test.before(async t => {
const module = await createTestingModule({ const module = await createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
plugins: {
copilot: { copilot: {
providers: {
openai: { openai: {
apiKey: process.env.COPILOT_OPENAI_API_KEY ?? '1', apiKey: process.env.COPILOT_OPENAI_API_KEY ?? '1',
}, },
@@ -95,6 +92,9 @@ test.before(async t => {
QuotaModule, QuotaModule,
CopilotModule, CopilotModule,
], ],
tapModule: builder => {
builder.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
},
}); });
const auth = module.get(AuthService); const auth = module.get(AuthService);
@@ -102,7 +102,7 @@ test.before(async t => {
const event = module.get(EventBus); const event = module.get(EventBus);
const context = module.get(CopilotContextService); const context = module.get(CopilotContextService);
const prompt = module.get(PromptService); const prompt = module.get(PromptService);
const provider = module.get(CopilotProviderService); const factory = module.get(CopilotProviderFactory);
const session = module.get(ChatSessionService); const session = module.get(ChatSessionService);
const workflow = module.get(CopilotWorkflowService); const workflow = module.get(CopilotWorkflowService);
const jobs = module.get(CopilotContextDocJob); const jobs = module.get(CopilotContextDocJob);
@@ -114,7 +114,7 @@ test.before(async t => {
t.context.event = event; t.context.event = event;
t.context.context = context; t.context.context = context;
t.context.prompt = prompt; t.context.prompt = prompt;
t.context.provider = provider; t.context.factory = factory;
t.context.session = session; t.context.session = session;
t.context.workflow = workflow; t.context.workflow = workflow;
t.context.jobs = jobs; t.context.jobs = jobs;
@@ -131,7 +131,7 @@ test.beforeEach(async t => {
Sinon.restore(); Sinon.restore();
const { module, auth, prompt } = t.context; const { module, auth, prompt } = t.context;
await module.initTestingDB(); await module.initTestingDB();
await prompt.onModuleInit(); await prompt.onApplicationBootstrap();
const user = await auth.signUp('test@affine.pro', '123456'); const user = await auth.signUp('test@affine.pro', '123456');
userId = user.id; userId = user.id;
}); });
@@ -730,10 +730,10 @@ test('should handle params correctly in chat session', async t => {
// ==================== provider ==================== // ==================== provider ====================
test('should be able to get provider', async t => { test('should be able to get provider', async t => {
const { provider } = t.context; const { factory } = t.context;
{ {
const p = await provider.getProviderByCapability( const p = await factory.getProviderByCapability(
CopilotCapability.TextToText CopilotCapability.TextToText
); );
t.is( t.is(
@@ -744,108 +744,40 @@ test('should be able to get provider', async t => {
} }
{ {
const p = await provider.getProviderByCapability( const p = await factory.getProviderByCapability(
CopilotCapability.TextToEmbedding CopilotCapability.ImageToImage,
{ model: 'lora/image-to-image' }
); );
t.is( t.is(
p?.type.toString(), p?.type.toString(),
'openai', 'fal',
'should get provider support text-to-embedding' 'should get provider support text-to-embedding'
); );
} }
{ {
const p = await provider.getProviderByCapability( const p = await factory.getProviderByCapability(
CopilotCapability.TextToImage
);
t.is(
p?.type.toString(),
'fal',
'should get provider support text-to-image'
);
}
{
const p = await provider.getProviderByCapability(
CopilotCapability.ImageToImage
);
t.is(
p?.type.toString(),
'fal',
'should get provider support image-to-image'
);
}
{
const p = await provider.getProviderByCapability(
CopilotCapability.ImageToText
);
t.is(
p?.type.toString(),
'fal',
'should get provider support image-to-text'
);
}
// text-to-image use fal by default, but this case can use
// model dall-e-3 to select openai provider
{
const p = await provider.getProviderByCapability(
CopilotCapability.TextToImage,
'dall-e-3'
);
t.is(
p?.type.toString(),
'openai',
'should get provider support text-to-image and model'
);
}
// gpt4o is not defined now, but it already published by openai
// we should check from online api if it is available
{
const p = await provider.getProviderByCapability(
CopilotCapability.ImageToText, CopilotCapability.ImageToText,
'gpt-4o-2024-08-06' { prefer: CopilotProviderType.FAL }
); );
t.is( t.is(
p?.type.toString(), p?.type.toString(),
'openai', 'fal',
'should get provider support text-to-image and model' 'should get provider support text-to-embedding'
); );
} }
// if a model is not defined and not available in online api // if a model is not defined and not available in online api
// it should return null // it should return null
{ {
const p = await provider.getProviderByCapability( const p = await factory.getProviderByCapability(
CopilotCapability.ImageToText, CopilotCapability.ImageToText,
'gpt-4-not-exist' { model: 'gpt-4-not-exist' }
); );
t.falsy(p, 'should not get provider'); t.falsy(p, 'should not get provider');
} }
}); });
test('should be able to register test provider', async t => {
const { provider } = t.context;
registerCopilotProvider(MockCopilotTestProvider);
const assertProvider = async (cap: CopilotCapability) => {
const p = await provider.getProviderByCapability(cap, 'test');
t.is(
p?.type,
CopilotProviderType.Test,
`should get test provider with ${cap}`
);
};
await assertProvider(CopilotCapability.TextToText);
await assertProvider(CopilotCapability.TextToEmbedding);
await assertProvider(CopilotCapability.TextToImage);
await assertProvider(CopilotCapability.ImageToImage);
await assertProvider(CopilotCapability.ImageToText);
});
// ==================== workflow ==================== // ==================== workflow ====================
// this test used to preview the final result of the workflow // this test used to preview the final result of the workflow
@@ -854,7 +786,6 @@ test.skip('should be able to preview workflow', async t => {
const { prompt, workflow, executors } = t.context; const { prompt, workflow, executors } = t.context;
executors.text.register(); executors.text.register();
registerCopilotProvider(OpenAIProvider);
for (const p of prompts) { for (const p of prompts) {
await prompt.set(p.name, p.model, p.messages, p.config); await prompt.set(p.name, p.model, p.messages, p.config);
@@ -878,8 +809,6 @@ test.skip('should be able to preview workflow', async t => {
} }
console.log('final stream result:', result); console.log('final stream result:', result);
t.truthy(result, 'should return result'); t.truthy(result, 'should return result');
unregisterCopilotProvider(OpenAIProvider.type);
}); });
const runWorkflow = async function* runWorkflow( const runWorkflow = async function* runWorkflow(
@@ -900,8 +829,6 @@ test('should be able to run pre defined workflow', async t => {
executors.text.register(); executors.text.register();
executors.html.register(); executors.html.register();
executors.json.register(); executors.json.register();
unregisterCopilotProvider(OpenAIProvider.type);
registerCopilotProvider(MockCopilotTestProvider);
const executor = Sinon.spy(executors.text, 'next'); const executor = Sinon.spy(executors.text, 'next');
@@ -941,17 +868,12 @@ test('should be able to run pre defined workflow', async t => {
} }
} }
} }
unregisterCopilotProvider(MockCopilotTestProvider.type);
registerCopilotProvider(OpenAIProvider);
}); });
test('should be able to run workflow', async t => { test('should be able to run workflow', async t => {
const { workflow, executors } = t.context; const { workflow, executors } = t.context;
executors.text.register(); executors.text.register();
unregisterCopilotProvider(OpenAIProvider.type);
registerCopilotProvider(MockCopilotTestProvider);
const executor = Sinon.spy(executors.text, 'next'); const executor = Sinon.spy(executors.text, 'next');
@@ -998,9 +920,6 @@ test('should be able to run workflow', async t => {
'graph params should correct' 'graph params should correct'
); );
} }
unregisterCopilotProvider(MockCopilotTestProvider.type);
registerCopilotProvider(OpenAIProvider);
}); });
// ==================== workflow executor ==================== // ==================== workflow executor ====================
@@ -1037,18 +956,16 @@ test('should be able to run executor', async t => {
}); });
test('should be able to run text executor', async t => { test('should be able to run text executor', async t => {
const { executors, provider, prompt } = t.context; const { executors, factory, prompt } = t.context;
executors.text.register(); executors.text.register();
const executor = getWorkflowExecutor(executors.text.type); const executor = getWorkflowExecutor(executors.text.type);
unregisterCopilotProvider(OpenAIProvider.type);
registerCopilotProvider(MockCopilotTestProvider);
await prompt.set('test', 'test', [ await prompt.set('test', 'test', [
{ role: 'system', content: 'hello {{word}}' }, { role: 'system', content: 'hello {{word}}' },
]); ]);
// mock provider // mock provider
const testProvider = const testProvider =
(await provider.getProviderByModel<CopilotCapability.TextToText>('test'))!; (await factory.getProviderByModel<CopilotCapability.TextToText>('test'))!;
const text = Sinon.spy(testProvider, 'generateText'); const text = Sinon.spy(testProvider, 'generateText');
const textStream = Sinon.spy(testProvider, 'generateTextStream'); const textStream = Sinon.spy(testProvider, 'generateTextStream');
@@ -1103,23 +1020,19 @@ test('should be able to run text executor', async t => {
} }
Sinon.restore(); Sinon.restore();
unregisterCopilotProvider(MockCopilotTestProvider.type);
registerCopilotProvider(OpenAIProvider);
}); });
test('should be able to run image executor', async t => { test('should be able to run image executor', async t => {
const { executors, provider, prompt } = t.context; const { executors, factory, prompt } = t.context;
executors.image.register(); executors.image.register();
const executor = getWorkflowExecutor(executors.image.type); const executor = getWorkflowExecutor(executors.image.type);
unregisterCopilotProvider(OpenAIProvider.type);
registerCopilotProvider(MockCopilotTestProvider);
await prompt.set('test', 'test', [ await prompt.set('test', 'test', [
{ role: 'user', content: 'tag1, tag2, tag3, {{#tags}}{{.}}, {{/tags}}' }, { role: 'user', content: 'tag1, tag2, tag3, {{#tags}}{{.}}, {{/tags}}' },
]); ]);
// mock provider // mock provider
const testProvider = const testProvider =
(await provider.getProviderByModel<CopilotCapability.TextToImage>('test'))!; (await factory.getProviderByModel<CopilotCapability.TextToImage>('test'))!;
const image = Sinon.spy(testProvider, 'generateImages'); const image = Sinon.spy(testProvider, 'generateImages');
const imageStream = Sinon.spy(testProvider, 'generateImagesStream'); const imageStream = Sinon.spy(testProvider, 'generateImagesStream');
@@ -1184,8 +1097,6 @@ test('should be able to run image executor', async t => {
} }
Sinon.restore(); Sinon.restore();
unregisterCopilotProvider(MockCopilotTestProvider.type);
registerCopilotProvider(OpenAIProvider);
}); });
test('CitationParser should replace citation placeholders with URLs', t => { test('CitationParser should replace citation placeholders with URLs', t => {

View File

@@ -4,9 +4,11 @@ import {
TestingModule as NestjsTestingModule, TestingModule as NestjsTestingModule,
TestingModuleBuilder, TestingModuleBuilder,
} from '@nestjs/testing'; } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { FunctionalityModules } from '../app.module'; import { FunctionalityModules } from '../app.module';
import { AFFiNELogger } from '../base'; import { AFFiNELogger, EventBus, JobQueue } from '../base';
import { createFactory, MockEventBus, MockJobQueue } from './mocks';
import { TEST_LOG_LEVEL } from './utils'; import { TEST_LOG_LEVEL } from './utils';
interface TestingModuleMetadata extends ModuleMetadata { interface TestingModuleMetadata extends ModuleMetadata {
@@ -15,10 +17,13 @@ interface TestingModuleMetadata extends ModuleMetadata {
export interface TestingModule extends NestjsTestingModule { export interface TestingModule extends NestjsTestingModule {
[Symbol.asyncDispose](): Promise<void>; [Symbol.asyncDispose](): Promise<void>;
create: ReturnType<typeof createFactory>;
queue: MockJobQueue;
event: MockEventBus;
} }
export async function createModule( export async function createModule(
metadata: TestingModuleMetadata metadata: TestingModuleMetadata = {}
): Promise<TestingModule> { ): Promise<TestingModule> {
const { tapModule, ...meta } = metadata; const { tapModule, ...meta } = metadata;
@@ -27,6 +32,12 @@ export async function createModule(
imports: [...FunctionalityModules, ...(meta.imports ?? [])], imports: [...FunctionalityModules, ...(meta.imports ?? [])],
}); });
builder
.overrideProvider(JobQueue)
.useValue(new MockJobQueue())
.overrideProvider(EventBus)
.useValue(new MockEventBus());
// when custom override happens // when custom override happens
if (tapModule) { if (tapModule) {
tapModule(builder); tapModule(builder);
@@ -44,6 +55,9 @@ export async function createModule(
module[Symbol.asyncDispose] = async () => { module[Symbol.asyncDispose] = async () => {
await module.close(); await module.close();
}; };
module.create = createFactory(module.get(PrismaClient));
module.queue = module.get(JobQueue);
module.event = module.get(EventBus);
return module; return module;
} }

View File

@@ -7,7 +7,6 @@ import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import request from 'supertest'; import request from 'supertest';
import { DocRendererModule } from '../../core/doc-renderer';
import { createTestingApp } from '../utils'; import { createTestingApp } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
@@ -45,13 +44,11 @@ function initTestStaticFiles(staticPath: string) {
} }
} }
test.before('init selfhost server', async t => { test.before(async t => {
const staticPath = new Package('@affine/server').join('static').value; const staticPath = new Package('@affine/server').join('static').value;
initTestStaticFiles(staticPath); initTestStaticFiles(staticPath);
const app = await createTestingApp({ const app = await createTestingApp();
imports: [DocRendererModule],
});
t.context.app = app; t.context.app = app;
}); });

View File

@@ -1,7 +1,7 @@
import { getCurrentUserQuery } from '@affine/graphql'; import { getCurrentUserQuery } from '@affine/graphql';
import { Mockers } from '../mocks'; import { Mockers } from '../../mocks';
import { app, e2e } from './test'; import { app, e2e } from '../test';
e2e('should create test app correctly', async t => { e2e('should create test app correctly', async t => {
t.truthy(app); t.truthy(app);
@@ -18,12 +18,7 @@ e2e('should mock queue work', async t => {
e2e('should handle http request', async t => { e2e('should handle http request', async t => {
const res = await app.GET('/info'); const res = await app.GET('/info');
t.is(res.status, 200); t.is(res.status, 200);
t.is(res.body.compatibility, AFFiNE.version); t.is(res.body.compatibility, env.version);
});
e2e('should handle gql request', async t => {
const user = await app.gql({ query: getCurrentUserQuery });
t.is(user.currentUser, null);
}); });
e2e('should create workspace with owner', async t => { e2e('should create workspace with owner', async t => {

View File

@@ -0,0 +1,46 @@
import { getCurrentUserQuery } from '@affine/graphql';
import { createApp } from '../create-app';
import { e2e } from '../test';
e2e('should init doc service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'doc';
await using app = await createApp();
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'doc');
await t.throwsAsync(app.gql({ query: getCurrentUserQuery }));
});
e2e('should init graphql service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'graphql';
await using app = await createApp();
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'graphql');
const user = await app.gql({ query: getCurrentUserQuery });
t.is(user.currentUser, null);
});
e2e('should init sync service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'sync';
await using app = await createApp();
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'sync');
});
e2e('should init renderer service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'renderer';
await using app = await createApp();
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});

View File

@@ -13,6 +13,7 @@ import {
AFFiNELogger, AFFiNELogger,
CacheInterceptor, CacheInterceptor,
CloudThrottlerGuard, CloudThrottlerGuard,
EventBus,
GlobalExceptionFilter, GlobalExceptionFilter,
JobQueue, JobQueue,
OneMB, OneMB,
@@ -23,6 +24,7 @@ import { Mailer } from '../../core/mail';
import { import {
createFactory, createFactory,
MockedUser, MockedUser,
MockEventBus,
MockJobQueue, MockJobQueue,
MockMailer, MockMailer,
MockUser, MockUser,
@@ -181,23 +183,19 @@ export class TestingApp extends NestApplication {
} }
} }
let GLOBAL_APP_INSTANCE: TestingApp | null = null;
export async function createApp( export async function createApp(
metadata: TestingAppMetadata = {} metadata: TestingAppMetadata = {}
): Promise<TestingApp> { ): Promise<TestingApp> {
if (GLOBAL_APP_INSTANCE) {
return GLOBAL_APP_INSTANCE;
}
const { buildAppModule } = await import('../../app.module'); const { buildAppModule } = await import('../../app.module');
const { tapModule, tapApp } = metadata; const { tapModule, tapApp } = metadata;
const builder = Test.createTestingModule({ const builder = Test.createTestingModule({
imports: [buildAppModule()], imports: [buildAppModule(globalThis.env)],
}); });
builder.overrideProvider(Mailer).useValue(new MockMailer()); builder.overrideProvider(Mailer).useValue(new MockMailer());
builder.overrideProvider(JobQueue).useValue(new MockJobQueue()); builder.overrideProvider(JobQueue).useValue(new MockJobQueue());
builder.overrideProvider(EventBus).useValue(new MockEventBus());
// when custom override happens // when custom override happens
if (tapModule) { if (tapModule) {
@@ -240,6 +238,5 @@ export async function createApp(
await app.init(); await app.init();
GLOBAL_APP_INSTANCE = app;
return app; return app;
} }

View File

@@ -0,0 +1,156 @@
import test from 'ava';
import { Env } from '../env';
const envs = { ...process.env };
test.beforeEach(() => {
process.env = { ...envs };
});
test('should init env', t => {
t.true(globalThis.env.testing);
});
test('should read NODE_ENV', t => {
process.env.NODE_ENV = 'test';
t.deepEqual(
['test', 'development', 'production'].map(envVal => {
process.env.NODE_ENV = envVal;
const env = new Env();
return env.NODE_ENV;
}),
['test', 'development', 'production']
);
t.throws(
() => {
process.env.NODE_ENV = 'unknown';
new Env();
},
{
message:
'Invalid value "unknown" for environment variable NODE_ENV, expected one of ["development","test","production"]',
}
);
});
test('should read NAMESPACE', t => {
t.deepEqual(
['dev', 'beta', 'production'].map(envVal => {
process.env.AFFINE_ENV = envVal;
const env = new Env();
return env.NAMESPACE;
}),
['dev', 'beta', 'production']
);
t.throws(() => {
process.env.AFFINE_ENV = 'unknown';
new Env();
});
});
test('should read DEPLOYMENT_TYPE', t => {
t.deepEqual(
['affine', 'selfhosted'].map(envVal => {
process.env.DEPLOYMENT_TYPE = envVal;
const env = new Env();
return env.DEPLOYMENT_TYPE;
}),
['affine', 'selfhosted']
);
t.throws(() => {
process.env.DEPLOYMENT_TYPE = 'unknown';
new Env();
});
});
test('should read FLAVOR', t => {
t.deepEqual(
['allinone', 'graphql', 'sync', 'renderer', 'doc', 'script'].map(envVal => {
process.env.SERVER_FLAVOR = envVal;
const env = new Env();
return env.FLAVOR;
}),
['allinone', 'graphql', 'sync', 'renderer', 'doc', 'script']
);
t.throws(
() => {
process.env.SERVER_FLAVOR = 'unknown';
new Env();
},
{
message:
'Invalid value "unknown" for environment variable SERVER_FLAVOR, expected one of ["allinone","graphql","sync","renderer","doc","script"]',
}
);
});
test('should read platform', t => {
t.deepEqual(
['gcp', 'unknown'].map(envVal => {
process.env.DEPLOYMENT_PLATFORM = envVal;
const env = new Env();
return env.platform;
}),
['gcp', 'unknown']
);
t.notThrows(() => {
process.env.PLATFORM = 'unknown';
new Env();
});
});
test('should tell flavors correctly', t => {
process.env.SERVER_FLAVOR = 'allinone';
t.deepEqual(new Env().flavors, {
graphql: true,
sync: true,
renderer: true,
doc: true,
script: true,
});
process.env.SERVER_FLAVOR = 'graphql';
t.deepEqual(new Env().flavors, {
graphql: true,
sync: false,
renderer: false,
doc: false,
script: false,
});
});
test('should tell selfhosted correctly', t => {
process.env.DEPLOYMENT_TYPE = 'selfhosted';
t.true(new Env().selfhosted);
process.env.DEPLOYMENT_TYPE = 'affine';
t.false(new Env().selfhosted);
});
test('should tell namespaces correctly', t => {
process.env.AFFINE_ENV = 'dev';
t.deepEqual(new Env().namespaces, {
canary: true,
beta: false,
production: false,
});
process.env.AFFINE_ENV = 'beta';
t.deepEqual(new Env().namespaces, {
canary: false,
beta: true,
production: false,
});
process.env.AFFINE_ENV = 'production';
t.deepEqual(new Env().namespaces, {
canary: false,
beta: false,
production: true,
});
});

View File

@@ -0,0 +1,113 @@
import { randomBytes } from 'node:crypto';
import {
CopilotCapability,
CopilotChatOptions,
CopilotEmbeddingOptions,
PromptMessage,
} from '../../plugins/copilot/providers';
import {
DEFAULT_DIMENSIONS,
OpenAIProvider,
} from '../../plugins/copilot/providers/openai';
import { sleep } from '../utils/utils';
export class MockCopilotProvider extends OpenAIProvider {
override readonly models = [
'test',
'gpt-4o',
'gpt-4o-2024-08-06',
'fast-sdxl/image-to-image',
'lcm-sd15-i2i',
'clarity-upscaler',
'imageutils/rembg',
];
override readonly capabilities = [
CopilotCapability.TextToText,
CopilotCapability.TextToEmbedding,
CopilotCapability.TextToImage,
CopilotCapability.ImageToImage,
CopilotCapability.ImageToText,
];
// ====== text to text ======
override async generateText(
messages: PromptMessage[],
model: string = 'test',
options: CopilotChatOptions = {}
): Promise<string> {
this.checkParams({ messages, model, options });
// make some time gap for history test case
await sleep(100);
return 'generate text to text';
}
override async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-4o-mini',
options: CopilotChatOptions = {}
): AsyncIterable<string> {
this.checkParams({ messages, model, options });
// make some time gap for history test case
await sleep(100);
const result = 'generate text to text stream';
for (const message of result) {
yield message;
if (options.signal?.aborted) {
break;
}
}
}
// ====== text to embedding ======
override async generateEmbedding(
messages: string | string[],
model: string,
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
messages = Array.isArray(messages) ? messages : [messages];
this.checkParams({ embeddings: messages, model, options });
// make some time gap for history test case
await sleep(100);
return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)];
}
// ====== text to image ======
override async generateImages(
messages: PromptMessage[],
model: string = 'test',
_options: {
signal?: AbortSignal;
user?: string;
} = {}
): Promise<Array<string>> {
const { content: prompt } = messages[0] || {};
if (!prompt) {
throw new Error('Prompt is required');
}
// make some time gap for history test case
await sleep(100);
// just let test case can easily verify the final prompt
return [`https://example.com/${model}.jpg`, prompt];
}
override async *generateImagesStream(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {
yield url;
}
}
}

View File

@@ -0,0 +1,35 @@
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { EventName } from '../../base/event/def';
export class MockEventBus {
private readonly stub = Sinon.createStubInstance(EventBus);
emit = this.stub.emitAsync;
emitAsync = this.stub.emitAsync;
broadcast = this.stub.broadcast;
last<Event extends EventName>(
name: Event
): { name: Event; payload: Events[Event] } {
const call = this.emitAsync
.getCalls()
.reverse()
.find(call => call.args[0] === name);
if (!call) {
throw new Error(`Event ${name} never called`);
}
// @ts-expect-error allow
return {
name,
payload: call.args[1],
};
}
count(name: EventName) {
return this.emitAsync.getCalls().filter(call => call.args[0] === name)
.length;
}
}

View File

@@ -4,7 +4,9 @@ export * from './user.mock';
export * from './workspace.mock'; export * from './workspace.mock';
export * from './workspace-user.mock'; export * from './workspace-user.mock';
import { MockCopilotProvider } from './copilot.mock';
import { MockDocMeta } from './doc-meta.mock'; import { MockDocMeta } from './doc-meta.mock';
import { MockEventBus } from './eventbus.mock';
import { MockMailer } from './mailer.mock'; import { MockMailer } from './mailer.mock';
import { MockJobQueue } from './queue.mock'; import { MockJobQueue } from './queue.mock';
import { MockTeamWorkspace } from './team-workspace.mock'; import { MockTeamWorkspace } from './team-workspace.mock';
@@ -22,4 +24,4 @@ export const Mockers = {
DocMeta: MockDocMeta, DocMeta: MockDocMeta,
}; };
export { MockJobQueue, MockMailer }; export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };

View File

@@ -1,7 +1,6 @@
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { ConfigModule } from '../../base/config';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models'; import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { createTestingModule, TestingModule } from '../utils'; import { createTestingModule, TestingModule } from '../utils';
@@ -126,13 +125,9 @@ test('should not switch user quota if the new quota is the same as the current o
}); });
test('should use pro plan as free for selfhost instance', async t => { test('should use pro plan as free for selfhost instance', async t => {
await using module = await createTestingModule({ // @ts-expect-error
imports: [ env.DEPLOYMENT_TYPE = 'selfhosted';
ConfigModule.forRoot({ await using module = await createTestingModule();
isSelfhosted: true,
}),
],
});
const models = module.get(Models); const models = module.get(Models);
const u1 = await models.user.create({ const u1 = await models.user.create({

View File

@@ -1,5 +1,3 @@
import '../../plugins/config';
import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common'; import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -89,13 +87,15 @@ class NonThrottledController {
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ const app = await createTestingApp({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
throttler: { throttle: {
throttlers: {
default: { default: {
ttl: 60, ttl: 60,
limit: 120, limit: 120,
}, },
}, },
},
}), }),
AppModule, AppModule,
], ],

View File

@@ -1,5 +1,3 @@
import '../../plugins/config';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { HttpStatus } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
@@ -30,8 +28,7 @@ const test = ava as TestFn<{
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ const app = await createTestingApp({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
plugins: {
oauth: { oauth: {
providers: { providers: {
google: { google: {
@@ -40,7 +37,6 @@ test.before(async t => {
}, },
}, },
}, },
},
}), }),
AppModule, AppModule,
], ],

View File

@@ -6,12 +6,13 @@ import Sinon from 'sinon';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { AppModule } from '../../app.module'; import { AppModule } from '../../app.module';
import { EventBus, Runtime } from '../../base'; import { EventBus } from '../../base';
import { ConfigModule } from '../../base/config'; import { ConfigFactory, ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { EarlyAccessType, FeatureService } from '../../core/features'; import { EarlyAccessType, FeatureService } from '../../core/features';
import { SubscriptionService } from '../../plugins/payment/service'; import { SubscriptionService } from '../../plugins/payment/service';
import { StripeFactory } from '../../plugins/payment/stripe';
import { import {
CouponType, CouponType,
encodeLookupKey, encodeLookupKey,
@@ -159,7 +160,6 @@ const test = ava as TestFn<{
service: SubscriptionService; service: SubscriptionService;
event: Sinon.SinonStubbedInstance<EventBus>; event: Sinon.SinonStubbedInstance<EventBus>;
feature: Sinon.SinonStubbedInstance<FeatureService>; feature: Sinon.SinonStubbedInstance<FeatureService>;
runtime: Sinon.SinonStubbedInstance<Runtime>;
stripe: { stripe: {
customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>; customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>;
prices: Sinon.SinonStubbedInstance<Stripe.PricesResource>; prices: Sinon.SinonStubbedInstance<Stripe.PricesResource>;
@@ -184,17 +184,13 @@ function getLastCheckoutPrice(checkoutStub: Sinon.SinonStub) {
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ const app = await createTestingApp({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
plugins: {
payment: { payment: {
stripe: { enabled: true,
keys: { showLifetimePrice: true,
APIKey: '1', apiKey: '1',
webhookKey: '1', webhookKey: '1',
}, },
},
},
},
}), }),
AppModule, AppModule,
], ],
@@ -203,18 +199,19 @@ test.before(async t => {
Sinon.createStubInstance(FeatureService) Sinon.createStubInstance(FeatureService)
); );
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus)); m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
}, },
}); });
t.context.event = app.get(EventBus); t.context.event = app.get(EventBus);
t.context.service = app.get(SubscriptionService); t.context.service = app.get(SubscriptionService);
t.context.feature = app.get(FeatureService); t.context.feature = app.get(FeatureService);
t.context.runtime = app.get(Runtime);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);
t.context.app = app; t.context.app = app;
const stripe = app.get(Stripe); const stripeFactory = app.get(StripeFactory);
await stripeFactory.onConfigInit();
const stripe = stripeFactory.stripe;
const stripeStubs = { const stripeStubs = {
customers: Sinon.stub(stripe.customers), customers: Sinon.stub(stripe.customers),
prices: Sinon.stub(stripe.prices), prices: Sinon.stub(stripe.prices),
@@ -234,6 +231,12 @@ test.beforeEach(async t => {
await t.context.app.initTestingDB(); await t.context.app.initTestingDB();
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1'); t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
app.get(ConfigFactory).override({
payment: {
showLifetimePrice: true,
},
});
await db.workspace.create({ await db.workspace.create({
data: { data: {
id: 'ws_1', id: 'ws_1',
@@ -249,11 +252,6 @@ test.beforeEach(async t => {
Sinon.reset(); Sinon.reset();
// default stubs
t.context.runtime.fetch
.withArgs('plugins.payment/showLifetimePrice')
.resolves(true);
// @ts-expect-error stub // @ts-expect-error stub
stripe.prices.list.callsFake((params: Stripe.PriceListParams) => { stripe.prices.list.callsFake((params: Stripe.PriceListParams) => {
if (params.lookup_keys) { if (params.lookup_keys) {
@@ -294,8 +292,13 @@ test('should list normal prices for authenticated user', async t => {
}); });
test('should not show lifetime price if not enabled', async t => { test('should not show lifetime price if not enabled', async t => {
const { service, runtime } = t.context; const { service, app } = t.context;
runtime.fetch.withArgs('plugins.payment/showLifetimePrice').resolves(false);
app.get(ConfigFactory).override({
payment: {
showLifetimePrice: false,
},
});
const prices = await service.listPrices(t.context.u1); const prices = await service.listPrices(t.context.u1);
@@ -539,8 +542,11 @@ test('should get correct pro plan price for checking out', async t => {
// any user, lifetime recurring // any user, lifetime recurring
{ {
feature.isEarlyAccessUser.resolves(false); feature.isEarlyAccessUser.resolves(false);
const runtime = app.get(Runtime); app.get(ConfigFactory).override({
await runtime.set('plugins.payment/showLifetimePrice', true); payment: {
showLifetimePrice: true,
},
});
await service.checkout( await service.checkout(
{ {
@@ -1181,8 +1187,12 @@ const onetimeYearlyInvoice: Stripe.Invoice = {
}; };
test('should not be able to checkout for lifetime recurring if not enabled', async t => { test('should not be able to checkout for lifetime recurring if not enabled', async t => {
const { service, u1, runtime } = t.context; const { service, u1, app } = t.context;
runtime.fetch.withArgs('plugins.payment/showLifetimePrice').resolves(false); app.get(ConfigFactory).override({
payment: {
showLifetimePrice: false,
},
});
await t.throwsAsync( await t.throwsAsync(
() => () =>
@@ -1202,7 +1212,13 @@ test('should not be able to checkout for lifetime recurring if not enabled', asy
}); });
test('should be able to checkout for lifetime recurring', async t => { test('should be able to checkout for lifetime recurring', async t => {
const { service, u1, stripe } = t.context; const { service, u1, stripe, app } = t.context;
app.get(ConfigFactory).override({
payment: {
showLifetimePrice: true,
},
});
await service.checkout( await service.checkout(
{ {

View File

@@ -1 +0,0 @@
export const gql = '/graphql';

View File

@@ -1,160 +1,11 @@
import { randomBytes } from 'node:crypto'; import { PromptConfig, PromptMessage } from '../../plugins/copilot/providers';
import {
DEFAULT_DIMENSIONS,
OpenAIProvider,
} from '../../plugins/copilot/providers/openai';
import {
CopilotCapability,
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageToImageProvider,
CopilotImageToTextProvider,
CopilotProviderType,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotTextToTextProvider,
PromptConfig,
PromptMessage,
} from '../../plugins/copilot/types';
import { NodeExecutorType } from '../../plugins/copilot/workflow/executor'; import { NodeExecutorType } from '../../plugins/copilot/workflow/executor';
import { import {
WorkflowGraph, WorkflowGraph,
WorkflowNodeType, WorkflowNodeType,
WorkflowParams, WorkflowParams,
} from '../../plugins/copilot/workflow/types'; } from '../../plugins/copilot/workflow/types';
import { gql } from './common';
import { TestingApp } from './testing-app'; import { TestingApp } from './testing-app';
import { sleep } from './utils';
// @ts-expect-error no error
export class MockCopilotTestProvider
extends OpenAIProvider
implements
CopilotTextToTextProvider,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotImageToImageProvider,
CopilotImageToTextProvider
{
static override readonly type = CopilotProviderType.Test;
override readonly availableModels = [
'test',
'gpt-4o',
'gpt-4o-2024-08-06',
'fast-sdxl/image-to-image',
'lcm-sd15-i2i',
'clarity-upscaler',
'imageutils/rembg',
];
static override readonly capabilities = [
CopilotCapability.TextToText,
CopilotCapability.TextToEmbedding,
CopilotCapability.TextToImage,
CopilotCapability.ImageToImage,
CopilotCapability.ImageToText,
];
constructor() {
super({ apiKey: '1' });
}
override getCapabilities(): CopilotCapability[] {
return MockCopilotTestProvider.capabilities;
}
static override assetsConfig(_config: any) {
return true;
}
override get type(): CopilotProviderType {
return CopilotProviderType.Test;
}
override async isModelAvailable(model: string): Promise<boolean> {
return this.availableModels.includes(model);
}
// ====== text to text ======
override async generateText(
messages: PromptMessage[],
model: string = 'test',
options: CopilotChatOptions = {}
): Promise<string> {
this.checkParams({ messages, model, options });
// make some time gap for history test case
await sleep(100);
return 'generate text to text';
}
override async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-4o-mini',
options: CopilotChatOptions = {}
): AsyncIterable<string> {
this.checkParams({ messages, model, options });
// make some time gap for history test case
await sleep(100);
const result = 'generate text to text stream';
for (const message of result) {
yield message;
if (options.signal?.aborted) {
break;
}
}
}
// ====== text to embedding ======
override async generateEmbedding(
messages: string | string[],
model: string,
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
messages = Array.isArray(messages) ? messages : [messages];
this.checkParams({ embeddings: messages, model, options });
// make some time gap for history test case
await sleep(100);
return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)];
}
// ====== text to image ======
override async generateImages(
messages: PromptMessage[],
model: string = 'test',
_options: {
signal?: AbortSignal;
user?: string;
} = {}
): Promise<Array<string>> {
const { content: prompt } = messages[0] || {};
if (!prompt) {
throw new Error('Prompt is required');
}
// make some time gap for history test case
await sleep(100);
// just let test case can easily verify the final prompt
return [`https://example.com/${model}.jpg`, prompt];
}
override async *generateImagesStream(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {
yield url;
}
}
}
export const cleanObject = ( export const cleanObject = (
obj: any[] | undefined, obj: any[] | undefined,
@@ -342,7 +193,7 @@ export async function addContextFile(
content: Buffer content: Buffer
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const res = await app const res = await app
.POST(gql) .POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field( .field(
'operations', 'operations',

View File

@@ -41,20 +41,17 @@ export async function createTestingApp(
moduleDef: TestingAppMetadata = {} moduleDef: TestingAppMetadata = {}
): Promise<TestingApp> { ): Promise<TestingApp> {
const module = await createTestingModule(moduleDef, false); const module = await createTestingModule(moduleDef, false);
const logger = new AFFiNELogger();
logger.setLogLevels([TEST_LOG_LEVEL]);
const app = module.createNestApplication<NestExpressApplication>({ const app = module.createNestApplication<NestExpressApplication>({
cors: true, cors: true,
bodyParser: true, bodyParser: true,
rawBody: true, rawBody: true,
logger,
}); });
app.useBodyParser('raw', { limit: 1 * OneMB }); app.useBodyParser('raw', { limit: 1 * OneMB });
const logger = new AFFiNELogger();
logger.setLogLevels([TEST_LOG_LEVEL]);
app.useLogger(logger);
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter())); app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.use( app.use(
graphqlUploadExpress({ graphqlUploadExpress({

View File

@@ -8,9 +8,10 @@ import {
} from '@nestjs/testing'; } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { AppModule, FunctionalityModules } from '../../app.module'; import { buildAppModule, FunctionalityModules } from '../../app.module';
import { AFFiNELogger, JobQueue, Runtime } from '../../base'; import { AFFiNELogger, JobQueue } from '../../base';
import { GqlModule } from '../../base/graphql'; import { GqlModule } from '../../base/graphql';
import { ServerConfigModule } from '../../core';
import { AuthGuard, AuthModule } from '../../core/auth'; import { AuthGuard, AuthModule } from '../../core/auth';
import { Mailer, MailModule } from '../../core/mail'; import { Mailer, MailModule } from '../../core/mail';
import { ModelsModule } from '../../models'; import { ModelsModule } from '../../models';
@@ -63,16 +64,18 @@ export async function createTestingModule(
autoInitialize = true autoInitialize = true
): Promise<TestingModule> { ): Promise<TestingModule> {
// setting up // setting up
let imports = moduleDef.imports ?? [AppModule]; let imports = moduleDef.imports ?? [buildAppModule(globalThis.env)];
imports = imports =
imports[0] === AppModule // @ts-expect-error
? [AppModule] imports[0].module?.name === 'AppModule'
? imports
: dedupeModules([ : dedupeModules([
...FunctionalityModules, ...FunctionalityModules,
ModelsModule, ModelsModule,
AuthModule, AuthModule,
GqlModule, GqlModule,
MailModule, MailModule,
ServerConfigModule,
...imports, ...imports,
]); ]);
@@ -101,10 +104,6 @@ export async function createTestingModule(
testingModule.initTestingDB = async () => { testingModule.initTestingDB = async () => {
await initTestingDB(module); await initTestingDB(module);
const runtime = module.get(Runtime);
// by pass password min length validation
await runtime.set('auth/password.min', 1);
}; };
testingModule.create = createFactory( testingModule.create = createFactory(

View File

@@ -1,6 +1,7 @@
import { INestApplicationContext, LogLevel } from '@nestjs/common'; import { INestApplicationContext, LogLevel } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import whywhywhy from 'why-is-node-running';
import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features'; import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features';
@@ -32,3 +33,21 @@ export async function initTestingDB(context: INestApplicationContext) {
export async function sleep(ms: number) { export async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
export function debugProcessHolding(ignorePrismaStack = true) {
setImmediate(() => {
whywhywhy({
error: message => {
// ignore prisma error
if (
ignorePrismaStack &&
(message.includes('Prisma') || message.includes('prisma'))
) {
return;
}
console.error(message);
},
});
});
}

View File

@@ -3,7 +3,7 @@ import test from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { Runtime, UseNamedGuard } from '../base'; import { ConfigFactory, UseNamedGuard } from '../base';
import { Public } from '../core/auth/guard'; import { Public } from '../core/auth/guard';
import { VersionService } from '../core/version/service'; import { VersionService } from '../core/version/service';
import { createTestingApp, TestingApp } from './utils'; import { createTestingApp, TestingApp } from './utils';
@@ -19,28 +19,28 @@ class GuardedController {
} }
let app: TestingApp; let app: TestingApp;
let runtime: Sinon.SinonStubbedInstance<Runtime>; let config: ConfigFactory;
let version: VersionService; let version: VersionService;
function checkVersion(enabled = true) { function checkVersion(enabled = true) {
runtime.fetch.withArgs('client/versionControl.enabled').resolves(enabled); config.override({
client: {
runtime.fetch versionControl: {
.withArgs('client/versionControl.requiredVersion') enabled,
.resolves('>=0.20.0'); requiredVersion: '>=0.20.0',
},
},
});
} }
test.before(async () => { test.before(async () => {
app = await createTestingApp({ app = await createTestingApp({
imports: [AppModule], imports: [AppModule],
controllers: [GuardedController], controllers: [GuardedController],
tapModule: m => {
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
},
}); });
runtime = app.get(Runtime);
version = app.get(VersionService, { strict: false }); version = app.get(VersionService, { strict: false });
config = app.get(ConfigFactory, { strict: false });
}); });
test.beforeEach(async () => { test.beforeEach(async () => {
@@ -74,9 +74,13 @@ test('should passthrough if version check is not enabled', async t => {
}); });
test('should passthrough is version range is invalid', async t => { test('should passthrough is version range is invalid', async t => {
runtime.fetch config.override({
.withArgs('client/versionControl.requiredVersion') client: {
.resolves('invalid'); versionControl: {
requiredVersion: 'invalid',
},
},
});
let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid'); let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
@@ -92,9 +96,13 @@ test('should pass if client version is allowed', async t => {
t.is(res.status, 200); t.is(res.status, 200);
runtime.fetch config.override({
.withArgs('client/versionControl.requiredVersion') client: {
.resolves('>=0.19.0'); versionControl: {
requiredVersion: '>=0.19.0',
},
},
});
res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0'); res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0');
@@ -120,9 +128,13 @@ test('should fail if client version is not set or invalid', async t => {
}); });
test('should tell upgrade if client version is lower than allowed', async t => { test('should tell upgrade if client version is lower than allowed', async t => {
runtime.fetch config.override({
.withArgs('client/versionControl.requiredVersion') client: {
.resolves('>=0.21.0 <=0.22.0'); versionControl: {
requiredVersion: '>=0.21.0 <=0.22.0',
},
},
});
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0'); let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
@@ -134,9 +146,13 @@ test('should tell upgrade if client version is lower than allowed', async t => {
}); });
test('should tell downgrade if client version is higher than allowed', async t => { test('should tell downgrade if client version is higher than allowed', async t => {
runtime.fetch config.override({
.withArgs('client/versionControl.requiredVersion') client: {
.resolves('>=0.20.0 <=0.22.0'); versionControl: {
requiredVersion: '>=0.20.0 <=0.22.0',
},
},
});
let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0'); let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0');
@@ -148,9 +164,13 @@ test('should tell downgrade if client version is higher than allowed', async t =
}); });
test('should test prerelease version', async t => { test('should test prerelease version', async t => {
runtime.fetch config.override({
.withArgs('client/versionControl.requiredVersion') client: {
.resolves('>=0.19.0'); versionControl: {
requiredVersion: '>=0.19.0',
},
},
});
let res = await app let res = await app
.GET('/guarded/test') .GET('/guarded/test')

View File

@@ -3,7 +3,6 @@ import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import type { Response } from 'supertest'; import type { Response } from 'supertest';
import { WorkerModule } from '../plugins/worker';
import { createTestingApp, TestingApp } from './utils'; import { createTestingApp, TestingApp } from './utils';
type TestContext = { type TestContext = {
@@ -13,9 +12,9 @@ type TestContext = {
const test = ava as TestFn<TestContext>; const test = ava as TestFn<TestContext>;
test.before(async t => { test.before(async t => {
const app = await createTestingApp({ // @ts-expect-error test
imports: [WorkerModule], env.DEPLOYMENT_TYPE = 'selfhosted';
}); const app = await createTestingApp();
t.context.app = app; t.context.app = app;
}); });

View File

@@ -1,21 +1,19 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { Config, SkipThrottle } from './base'; import { SkipThrottle } from './base';
import { Public } from './core/auth'; import { Public } from './core/auth';
@Controller('/info') @Controller('/info')
export class AppController { export class AppController {
constructor(private readonly config: Config) {}
@SkipThrottle() @SkipThrottle()
@Public() @Public()
@Get() @Get()
info() { info() {
return { return {
compatibility: this.config.version, compatibility: env.version,
message: `AFFiNE ${this.config.version} Server`, message: `AFFiNE ${env.version} Server`,
type: this.config.type, type: env.DEPLOYMENT_TYPE,
flavor: this.config.flavor.type, flavor: env.FLAVOR,
}; };
} }
} }

View File

@@ -1,27 +1,19 @@
import { import { DynamicModule, ExecutionContext } from '@nestjs/common';
DynamicModule,
ExecutionContext,
ForwardReference,
Logger,
Module,
} from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ClsPluginTransactional } from '@nestjs-cls/transactional'; import { ClsPluginTransactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { get } from 'lodash-es';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { import {
getOptionalModuleMetadata,
getRequestIdFromHost, getRequestIdFromHost,
getRequestIdFromRequest, getRequestIdFromRequest,
ScannerModule, ScannerModule,
} from './base'; } from './base';
import { CacheModule } from './base/cache'; import { CacheModule } from './base/cache';
import { AFFiNEConfig, ConfigModule, mergeConfigOverride } from './base/config'; import { ConfigModule } from './base/config';
import { ErrorModule } from './base/error'; import { ErrorModule } from './base/error';
import { EventModule } from './base/event'; import { EventModule } from './base/event';
import { GqlModule } from './base/graphql'; import { GqlModule } from './base/graphql';
@@ -32,12 +24,11 @@ import { MetricsModule } from './base/metrics';
import { MutexModule } from './base/mutex'; import { MutexModule } from './base/mutex';
import { PrismaModule } from './base/prisma'; import { PrismaModule } from './base/prisma';
import { RedisModule } from './base/redis'; import { RedisModule } from './base/redis';
import { RuntimeModule } from './base/runtime';
import { StorageProviderModule } from './base/storage'; import { StorageProviderModule } from './base/storage';
import { RateLimiterModule } from './base/throttler'; import { RateLimiterModule } from './base/throttler';
import { WebSocketModule } from './base/websocket'; import { WebSocketModule } from './base/websocket';
import { AuthModule } from './core/auth'; import { AuthModule } from './core/auth';
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config'; import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
import { DocStorageModule } from './core/doc'; import { DocStorageModule } from './core/doc';
import { DocRendererModule } from './core/doc-renderer'; import { DocRendererModule } from './core/doc-renderer';
import { DocServiceModule } from './core/doc-service'; import { DocServiceModule } from './core/doc-service';
@@ -52,10 +43,16 @@ import { SyncModule } from './core/sync';
import { UserModule } from './core/user'; import { UserModule } from './core/user';
import { VersionModule } from './core/version'; import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces'; import { WorkspaceModule } from './core/workspaces';
import { Env } from './env';
import { ModelsModule } from './models'; import { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins'; import { CaptchaModule } from './plugins/captcha';
import { CopilotModule } from './plugins/copilot';
import { CustomerIoModule } from './plugins/customerio';
import { GCloudModule } from './plugins/gcloud';
import { LicenseModule } from './plugins/license'; import { LicenseModule } from './plugins/license';
import { ENABLED_PLUGINS } from './plugins/registry'; import { OAuthModule } from './plugins/oauth';
import { PaymentModule } from './plugins/payment';
import { WorkerModule } from './plugins/worker';
export const FunctionalityModules = [ export const FunctionalityModules = [
ClsModule.forRoot({ ClsModule.forRoot({
@@ -91,126 +88,64 @@ export const FunctionalityModules = [
}), }),
], ],
}), }),
ConfigModule.forRoot(), LoggerModule,
RuntimeModule,
ScannerModule, ScannerModule,
PrismaModule,
EventModule, EventModule,
ConfigModule,
RedisModule, RedisModule,
CacheModule, CacheModule,
MutexModule, MutexModule,
PrismaModule,
MetricsModule, MetricsModule,
RateLimiterModule, RateLimiterModule,
StorageProviderModule, StorageProviderModule,
HelpersModule, HelpersModule,
ErrorModule, ErrorModule,
LoggerModule,
WebSocketModule, WebSocketModule,
JobModule.forRoot(), JobModule.forRoot(),
ModelsModule,
]; ];
function filterOptionalModule(
config: AFFiNEConfig,
module: AFFiNEModule | Promise<DynamicModule> | ForwardReference<any>
) {
// can't deal with promise or forward reference
if (module instanceof Promise || 'forwardRef' in module) {
return module;
}
const requirements = getOptionalModuleMetadata(module, 'requires');
// if condition not set or condition met, include the module
if (requirements?.length) {
const nonMetRequirements = requirements.filter(c => {
const value = get(config, c);
return (
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim().length === 0)
);
});
if (nonMetRequirements.length) {
const name = 'module' in module ? module.module.name : module.name;
if (!config.node.test) {
new Logger(name).warn(
`${name} is not enabled because of the required configuration is not satisfied.`,
'Unsatisfied configuration:',
...nonMetRequirements.map(config => ` AFFiNE.${config}`)
);
}
return null;
}
}
const predicator = getOptionalModuleMetadata(module, 'if');
if (predicator && !predicator(config)) {
return null;
}
const contribution = getOptionalModuleMetadata(module, 'contributesTo');
if (contribution) {
ADD_ENABLED_FEATURES(contribution);
}
const subModules = getOptionalModuleMetadata(module, 'imports');
const filteredSubModules = subModules
?.map(subModule => filterOptionalModule(config, subModule))
.filter(Boolean);
Reflect.defineMetadata('imports', filteredSubModules, module);
return module;
}
export class AppModuleBuilder { export class AppModuleBuilder {
private readonly modules: AFFiNEModule[] = []; private readonly modules: AFFiNEModule[] = [];
constructor(private readonly config: AFFiNEConfig) {}
use(...modules: AFFiNEModule[]): this { use(...modules: AFFiNEModule[]): this {
modules.forEach(m => { modules.forEach(m => {
const result = filterOptionalModule(this.config, m);
if (result) {
this.modules.push(m); this.modules.push(m);
}
}); });
return this; return this;
} }
useIf( useIf(predicator: () => boolean, ...modules: AFFiNEModule[]): this {
predicator: (config: AFFiNEConfig) => boolean, if (predicator()) {
...modules: AFFiNEModule[]
): this {
if (predicator(this.config)) {
this.use(...modules); this.use(...modules);
} }
return this; return this;
} }
compile() { compile(): DynamicModule {
@Module({
imports: this.modules,
controllers: [AppController],
})
class AppModule {} class AppModule {}
return AppModule; return {
module: AppModule,
imports: this.modules,
controllers: [AppController],
};
} }
} }
export function buildAppModule() { export function buildAppModule(env: Env) {
AFFiNE = mergeConfigOverride(AFFiNE); const factor = new AppModuleBuilder();
const factor = new AppModuleBuilder(AFFiNE);
factor factor
// basic // basic
.use(...FunctionalityModules) .use(...FunctionalityModules)
.use(ModelsModule)
// enable schedule module on graphql server and doc service // enable schedule module on graphql server and doc service
.useIf( .useIf(
config => config.flavor.graphql || config.flavor.doc, () => env.flavors.graphql || env.flavors.doc,
ScheduleModule.forRoot() ScheduleModule.forRoot()
) )
@@ -219,46 +154,41 @@ export function buildAppModule() {
// business modules // business modules
.use( .use(
ServerConfigModule,
FeatureModule, FeatureModule,
QuotaModule, QuotaModule,
DocStorageModule, DocStorageModule,
NotificationModule, NotificationModule,
MailModule MailModule
) )
// renderer server only
.useIf(() => env.flavors.renderer, DocRendererModule)
// sync server only // sync server only
.useIf(config => config.flavor.sync, SyncModule) .useIf(() => env.flavors.sync, SyncModule)
// graphql server only // graphql server only
.useIf( .useIf(
config => config.flavor.graphql, () => env.flavors.graphql,
VersionModule,
GqlModule, GqlModule,
VersionModule,
StorageModule, StorageModule,
ServerConfigModule, ServerConfigResolverModule,
WorkspaceModule, WorkspaceModule,
LicenseModule LicenseModule,
PaymentModule,
CopilotModule,
CaptchaModule,
OAuthModule,
CustomerIoModule
) )
// doc service only // doc service only
.useIf(config => config.flavor.doc, DocServiceModule) .useIf(() => env.flavors.doc, DocServiceModule)
// self hosted server only // self hosted server only
.useIf(config => config.isSelfhosted, SelfhostModule) .useIf(() => env.selfhosted, WorkerModule, SelfhostModule)
.useIf(config => config.flavor.renderer, DocRendererModule);
// plugin modules // gcloud
ENABLED_PLUGINS.forEach(name => { .useIf(() => env.gcp, GCloudModule);
const plugin = REGISTERED_PLUGINS.get(name);
if (!plugin) {
new Logger('AppBuilder').warn(`Unknown plugin ${name}`);
return;
}
factor.use(plugin);
});
return factor.compile(); return factor.compile();
} }
export const AppModule = buildAppModule(); export const AppModule = buildAppModule(env);

View File

@@ -7,11 +7,11 @@ import {
AFFiNELogger, AFFiNELogger,
CacheInterceptor, CacheInterceptor,
CloudThrottlerGuard, CloudThrottlerGuard,
Config,
GlobalExceptionFilter, GlobalExceptionFilter,
} from './base'; } from './base';
import { SocketIoAdapter } from './base/websocket'; import { SocketIoAdapter } from './base/websocket';
import { AuthGuard } from './core/auth'; import { AuthGuard } from './core/auth';
import { ENABLED_FEATURES } from './core/config/server-feature';
import { serverTimingAndCache } from './middleware/timing'; import { serverTimingAndCache } from './middleware/timing';
const OneMB = 1024 * 1024; const OneMB = 1024 * 1024;
@@ -29,9 +29,10 @@ export async function createApp() {
app.useBodyParser('raw', { limit: 100 * OneMB }); app.useBodyParser('raw', { limit: 100 * OneMB });
app.useLogger(app.get(AFFiNELogger)); app.useLogger(app.get(AFFiNELogger));
const config = app.get(Config);
if (AFFiNE.server.path) { if (config.server.path) {
app.setGlobalPrefix(AFFiNE.server.path); app.setGlobalPrefix(config.server.path);
} }
app.use(serverTimingAndCache); app.use(serverTimingAndCache);
@@ -49,22 +50,12 @@ export async function createApp() {
app.use(cookieParser()); app.use(cookieParser());
// only enable shutdown hooks in production // only enable shutdown hooks in production
// https://docs.nestjs.com/fundamentals/lifecycle-events#application-shutdown // https://docs.nestjs.com/fundamentals/lifecycle-events#application-shutdown
if (AFFiNE.NODE_ENV === 'production') { if (env.prod) {
app.enableShutdownHooks(); app.enableShutdownHooks();
} }
const adapter = new SocketIoAdapter(app); const adapter = new SocketIoAdapter(app);
app.useWebSocketAdapter(adapter); app.useWebSocketAdapter(adapter);
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
const mixpanel = await import('mixpanel');
mixpanel
.init(AFFiNE.metrics.telemetry.token)
.track('selfhost-server-started', {
version: AFFiNE.version,
features: Array.from(ENABLED_FEATURES),
});
}
return app; return app;
} }

View File

@@ -0,0 +1,90 @@
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { ConfigFactory, ConfigModule } from '..';
import { Config } from '../config';
const module = await createModule();
test.after.always(async () => {
await module.close();
});
test('should create config', t => {
const config = module.get(Config);
t.is(typeof config.auth.passwordRequirements.max, 'number');
t.is(typeof config.job.queue, 'object');
});
test('should override config', async t => {
await using module = await createModule({
imports: [
ConfigModule.override({
auth: {
passwordRequirements: {
max: 100,
min: 6,
},
},
job: {
queues: {
notification: {
concurrency: 1000,
},
},
},
}),
],
});
const config = module.get(Config);
const configFactory = module.get(ConfigFactory);
t.deepEqual(config.auth.passwordRequirements, {
max: 100,
min: 6,
});
configFactory.override({
auth: {
passwordRequirements: {
max: 10,
},
},
});
t.deepEqual(config.auth.passwordRequirements, {
max: 10,
min: 6,
});
});
test('should validate config', t => {
const config = module.get(ConfigFactory);
t.notThrows(() =>
config.validate([
{
module: 'auth',
key: 'passwordRequirements',
value: { max: 10, min: 6 },
},
])
);
t.throws(
() =>
config.validate([
{
module: 'auth',
key: 'passwordRequirements',
value: { max: 10, min: 10 },
},
]),
{
message: `Invalid config for module [auth] with key [passwordRequirements]
Value: {"max":10,"min":10}
Error: Minimum length of password must be less than maximum length`,
}
);
});

View File

@@ -0,0 +1,3 @@
import { ApplyType } from '../utils';
export class Config extends ApplyType<AppConfig>() {}

View File

@@ -1,55 +0,0 @@
import type { LeafPaths } from '../utils/types';
import { AppStartupConfig } from './types';
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
export type ServerFlavor =
| 'allinone'
| 'graphql'
| 'sync'
| 'renderer'
| 'doc'
| 'script';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
export enum DeploymentType {
Affine = 'affine',
Selfhosted = 'selfhosted',
}
export type ConfigPaths = LeafPaths<AppStartupConfig, '', '......'>;
export interface PreDefinedAFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
serverId: string;
serverName: string;
readonly projectRoot: string;
readonly AFFINE_ENV: AFFINE_ENV;
readonly NODE_ENV: NODE_ENV;
readonly version: string;
readonly type: DeploymentType;
readonly isSelfhosted: boolean;
readonly flavor: { type: string } & { [key in ServerFlavor]: boolean };
readonly affine: { canary: boolean; beta: boolean; stable: boolean };
readonly node: {
prod: boolean;
dev: boolean;
test: boolean;
};
readonly deploy: boolean;
}
export interface AppPluginsConfig {}
export type AFFiNEConfig = PreDefinedAFFiNEConfig &
AppStartupConfig &
AppPluginsConfig;
declare global {
// oxlint-disable-next-line @typescript-eslint/no-namespace
namespace globalThis {
// oxlint-disable-next-line no-var
var AFFiNE: AFFiNEConfig;
}
}

View File

@@ -1,142 +0,0 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import pkg from '../../../package.json' with { type: 'json' };
import {
AFFINE_ENV,
AFFiNEConfig,
DeploymentType,
NODE_ENV,
PreDefinedAFFiNEConfig,
ServerFlavor,
} from './def';
import { readEnv } from './env';
import { defaultStartupConfig } from './register';
function expectFlavor(flavor: ServerFlavor, expected: ServerFlavor) {
return flavor === expected || flavor === 'allinone';
}
function getPredefinedAFFiNEConfig(): PreDefinedAFFiNEConfig {
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'production', [
'development',
'test',
'production',
]);
const AFFINE_ENV = readEnv<AFFINE_ENV>('AFFINE_ENV', 'production', [
'dev',
'beta',
'production',
]);
const flavor = readEnv<ServerFlavor>('SERVER_FLAVOR', 'allinone', [
'allinone',
'graphql',
'sync',
'renderer',
'doc',
'script',
]);
const deploymentType = readEnv<DeploymentType>(
'DEPLOYMENT_TYPE',
NODE_ENV === 'development'
? DeploymentType.Affine
: DeploymentType.Selfhosted,
Object.values(DeploymentType)
);
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
const affine = {
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
const node = {
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
return {
ENV_MAP: {},
NODE_ENV,
AFFINE_ENV,
serverId: 'some-randome-uuid',
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
version: pkg.version,
type: deploymentType,
isSelfhosted,
flavor: {
type: flavor,
allinone: flavor === 'allinone',
graphql: expectFlavor(flavor, 'graphql'),
sync: expectFlavor(flavor, 'sync'),
renderer: expectFlavor(flavor, 'renderer'),
doc: expectFlavor(flavor, 'doc'),
script: expectFlavor(flavor, 'script'),
},
affine,
node,
deploy: !node.dev && !node.test,
projectRoot: resolve(fileURLToPath(import.meta.url), '../../../../'),
};
}
export function getAFFiNEConfigModifier(): AFFiNEConfig {
const predefined = getPredefinedAFFiNEConfig() as AFFiNEConfig;
return chainableProxy(predefined);
}
function merge(a: any, b: any) {
if (typeof b !== 'object' || b instanceof Map || b instanceof Set) {
return b;
}
if (Array.isArray(b)) {
if (Array.isArray(a)) {
return a.concat(b);
}
return b;
}
const result = { ...a };
Object.keys(b).forEach(key => {
result[key] = merge(result[key], b[key]);
});
return result;
}
export function mergeConfigOverride(override: any) {
return merge(defaultStartupConfig, override);
}
function chainableProxy(obj: any) {
const keys: Set<string> = new Set(Object.keys(obj));
return new Proxy(obj, {
get(target, prop) {
if (!(prop in target)) {
keys.add(prop as string);
target[prop] = chainableProxy({});
}
return target[prop];
},
set(target, prop, value) {
keys.add(prop as string);
if (
typeof value === 'object' &&
!(
value instanceof Map ||
value instanceof Set ||
value instanceof Array
)
) {
value = chainableProxy(value);
}
target[prop] = value;
return true;
},
ownKeys() {
return Array.from(keys);
},
});
}

View File

@@ -1,11 +1,9 @@
import { set } from 'lodash-es'; export type EnvConfigType = 'string' | 'integer' | 'float' | 'boolean';
import type { AFFiNEConfig, EnvConfigType } from './def';
/** /**
* parse number value from environment variables * parse number value from environment variables
*/ */
function int(value: string) { function integer(value: string) {
const n = parseInt(value); const n = parseInt(value);
return Number.isNaN(n) ? undefined : n; return Number.isNaN(n) ? undefined : n;
} }
@@ -20,7 +18,7 @@ function boolean(value: string) {
} }
const envParsers: Record<EnvConfigType, (value: string) => unknown> = { const envParsers: Record<EnvConfigType, (value: string) => unknown> = {
int, integer,
float, float,
boolean, boolean,
string: value => value, string: value => value,
@@ -33,38 +31,3 @@ export function parseEnvValue(value: string | undefined, type: EnvConfigType) {
return envParsers[type](value); return envParsers[type](value);
} }
export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
for (const env in rawConfig.ENV_MAP) {
const config = rawConfig.ENV_MAP[env];
const [path, value] =
typeof config === 'string'
? [config, parseEnvValue(process.env[env], 'string')]
: [config[0], parseEnvValue(process.env[env], config[1] ?? 'string')];
if (value !== undefined) {
set(rawConfig, path, value);
}
}
}
export function readEnv<T>(
env: string,
defaultValue: T,
availableValues?: T[]
) {
const value = process.env[env];
if (value === undefined) {
return defaultValue;
}
if (availableValues && !availableValues.includes(value as any)) {
throw new Error(
`Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join(
', '
)}]`
);
}
return value as T;
}

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { merge } from 'lodash-es';
import { InvalidAppConfig } from '../error';
import { APP_CONFIG_DESCRIPTORS, getDefaultConfig } from './register';
export const OVERRIDE_CONFIG_TOKEN = Symbol('OVERRIDE_CONFIG_TOKEN');
@Injectable()
export class ConfigFactory {
readonly #config: DeepReadonly<AppConfig>;
constructor(
@Inject(OVERRIDE_CONFIG_TOKEN)
@Optional()
private readonly overrides: DeepPartial<AppConfig> = {}
) {
this.#config = this.loadDefault();
}
get config() {
return this.#config;
}
override(updates: DeepPartial<AppConfig>) {
merge(this.#config, updates);
}
validate(updates: Array<{ module: string; key: string; value: any }>) {
const errors: string[] = [];
updates.forEach(update => {
const descriptor = APP_CONFIG_DESCRIPTORS[update.module]?.[update.key];
if (!descriptor) {
errors.push(
`Invalid config for module [${update.module}] with unknown key [${update.key}]`
);
return;
}
const { success, error } = descriptor.validate(update.value);
if (!success) {
error.issues.forEach(issue => {
errors.push(`Invalid config for module [${update.module}] with key [${update.key}]
Value: ${JSON.stringify(update.value)}
Error: ${issue.message}`);
});
}
});
if (errors.length > 0) {
throw new InvalidAppConfig(errors.join('\n'));
}
}
private loadDefault(): DeepReadonly<AppConfig> {
const config = getDefaultConfig();
return merge(config, this.overrides);
}
}

View File

@@ -1,37 +1,29 @@
import { DynamicModule, FactoryProvider } from '@nestjs/common'; import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { merge } from 'lodash-es';
import { AFFiNEConfig } from './def'; import { Config } from './config';
import { Config } from './provider'; import { ConfigFactory, OVERRIDE_CONFIG_TOKEN } from './factory';
import { ConfigProvider } from './provider';
export * from './def';
export * from './default';
export { applyEnvToConfig, parseEnvValue } from './env';
export * from './provider';
export { defineRuntimeConfig, defineStartupConfig } from './register';
export type { AppConfig, ConfigItem, ModuleConfig } from './types';
function createConfigProvider(
override?: DeepPartial<Config>
): FactoryProvider<Config> {
return {
provide: Config,
useFactory: () => {
return Object.freeze(merge({}, globalThis.AFFiNE, override));
},
inject: [],
};
}
@Global()
@Module({
providers: [ConfigProvider, ConfigFactory],
exports: [ConfigProvider, ConfigFactory],
})
export class ConfigModule { export class ConfigModule {
static forRoot = (override?: DeepPartial<AFFiNEConfig>): DynamicModule => { static override(overrides: DeepPartial<AppConfigSchema> = {}): DynamicModule {
const provider = createConfigProvider(override); const provider: Provider = {
provide: OVERRIDE_CONFIG_TOKEN,
useValue: overrides,
};
return { return {
global: true, global: true,
module: ConfigModule, module: class ConfigOverrideModule {},
providers: [provider], providers: [provider],
exports: [provider], exports: [provider],
}; };
};
} }
}
export { Config, ConfigFactory };
export { defineModuleConfig, type JSONSchema } from './register';

View File

@@ -1,16 +1,12 @@
import { ApplyType } from '../utils/types'; import { FactoryProvider } from '@nestjs/common';
import { AFFiNEConfig } from './def';
/** import { Config } from './config';
* @example import { ConfigFactory } from './factory';
*
* import { Config } from '@affine/server' export const ConfigProvider: FactoryProvider = {
* provide: Config,
* class TestConfig { useFactory: (factory: ConfigFactory) => {
* constructor(private readonly config: Config) {} return factory.config;
* test() { },
* return this.config.env inject: [ConfigFactory],
* } };
* }
*/
export class Config extends ApplyType<AFFiNEConfig>() {}

View File

@@ -1,66 +1,239 @@
import { Prisma, RuntimeConfigType } from '@prisma/client'; import { once, set } from 'lodash-es';
import { get, merge, set } from 'lodash-es'; import { z } from 'zod';
import { import { type EnvConfigType, parseEnvValue } from './env';
AppModulesConfigDef, import { AppConfigByPath } from './types';
AppStartupConfig,
ModuleRuntimeConfigDescriptions,
ModuleStartupConfigDescriptions,
} from './types';
export const defaultStartupConfig: AppStartupConfig = {} as any; export type JSONSchema = { description?: string } & (
export const defaultRuntimeConfig: Record< | { type?: undefined; oneOf?: JSONSchema[] }
string, | {
Prisma.RuntimeConfigCreateInput type: 'string' | 'number' | 'boolean';
> = {} as any; enum?: string[];
export function runtimeConfigType(val: any): RuntimeConfigType {
if (Array.isArray(val)) {
return RuntimeConfigType.Array;
} }
| {
switch (typeof val) { type: 'array';
case 'string': items?: JSONSchema;
return RuntimeConfigType.String;
case 'number':
return RuntimeConfigType.Number;
case 'boolean':
return RuntimeConfigType.Boolean;
default:
return RuntimeConfigType.Object;
} }
| {
type: 'object';
properties?: Record<string, JSONSchema>;
} }
function registerRuntimeConfig<T extends keyof AppModulesConfigDef>(
module: T,
configs: ModuleRuntimeConfigDescriptions<T>
) {
Object.entries(configs).forEach(([key, value]) => {
defaultRuntimeConfig[`${module}/${key}`] = {
id: `${module}/${key}`,
module,
key,
description: value.desc,
value: value.default,
type: runtimeConfigType(value.default),
};
});
}
export function defineStartupConfig<T extends keyof AppModulesConfigDef>(
module: T,
configs: ModuleStartupConfigDescriptions<AppModulesConfigDef[T]>
) {
set(
defaultStartupConfig,
module,
merge(get(defaultStartupConfig, module, {}), configs)
); );
type ConfigType = EnvConfigType | 'array' | 'object' | 'any';
export type ConfigDescriptor<T> = {
desc: string;
type: ConfigType;
validate: (value: T) => z.SafeParseReturnType<T, T>;
schema: JSONSchema;
default: T;
env?: [string, EnvConfigType];
link?: string;
};
type ConfigDefineDescriptor<T> = {
desc: string;
default: T;
validate?: (value: T) => boolean;
shape?: z.ZodType<T>;
env?: string | [string, EnvConfigType];
link?: string;
schema?: JSONSchema;
};
function typeFromShape(shape: z.ZodType<any>): ConfigType {
switch (shape.constructor) {
case z.ZodString:
return 'string';
case z.ZodNumber:
return 'float';
case z.ZodBoolean:
return 'boolean';
case z.ZodArray:
return 'array';
case z.ZodObject:
return 'object';
default:
return 'any';
}
} }
export function defineRuntimeConfig<T extends keyof AppModulesConfigDef>( function shapeFromType(type: ConfigType): z.ZodType<any> {
module: T, switch (type) {
configs: ModuleRuntimeConfigDescriptions<T> case 'string':
) { return z.string();
registerRuntimeConfig(module, configs); case 'float':
return z.number();
case 'boolean':
return z.boolean();
case 'integer':
return z.number().int();
case 'array':
return z.array(z.any());
case 'object':
return z.object({});
default:
return z.any();
}
}
function typeFromSchema(schema: JSONSchema): ConfigType {
if ('type' in schema) {
switch (schema.type) {
case 'string':
return 'string';
case 'number':
return 'float';
case 'boolean':
return 'boolean';
case 'array':
return 'array';
case 'object':
return 'object';
}
}
return 'any';
}
function schemaFromType(type: ConfigType): JSONSchema['type'] {
switch (type) {
case 'any':
return undefined;
case 'float':
case 'integer':
return 'number';
default:
return type;
}
}
function typeFromDefault<T>(defaultValue: T): ConfigType {
if (Array.isArray(defaultValue)) {
return 'array';
}
switch (typeof defaultValue) {
case 'string':
return 'string';
case 'number':
return 'float';
case 'boolean':
return 'boolean';
case 'object':
return 'object';
default:
return 'any';
}
}
function standardizeDescriptor<T>(
desc: ConfigDefineDescriptor<T>
): ConfigDescriptor<T> {
const env = desc.env
? Array.isArray(desc.env)
? desc.env
: ([desc.env, 'string'] as [string, EnvConfigType])
: undefined;
let type: ConfigType = 'any';
if (desc.default !== undefined && desc.default !== null) {
type = typeFromDefault(desc.default);
} else if (env) {
type = env[1];
} else if (desc.shape) {
type = typeFromShape(desc.shape);
} else if (desc.schema) {
type = typeFromSchema(desc.schema);
}
const shape = desc.shape ?? shapeFromType(type);
return {
desc: desc.desc,
default: desc.default,
type,
validate: (value: T) => {
return shape.safeParse(value);
},
env,
link: desc.link,
schema: {
type: schemaFromType(type),
description: desc.desc,
...desc.schema,
},
};
}
type ModuleConfigDescriptors<T> = {
[K in keyof T]: ConfigDefineDescriptor<T[K]>;
};
export const APP_CONFIG_DESCRIPTORS: Record<
string,
Record<string, ConfigDescriptor<any>>
> = {};
export const getDescriptors = once(() => {
return Object.entries(APP_CONFIG_DESCRIPTORS).map(
([module, descriptors]) => ({
module,
descriptors: Object.entries(descriptors).map(([key, descriptor]) => ({
key,
descriptor,
})),
})
);
});
export function defineModuleConfig<T extends keyof AppConfigSchema>(
module: T,
defs: ModuleConfigDescriptors<AppConfigByPath<T>>
) {
const descriptors: Record<string, ConfigDescriptor<any>> = {};
Object.entries(defs).forEach(([key, desc]) => {
descriptors[key] = standardizeDescriptor(
desc as ConfigDefineDescriptor<any>
);
});
APP_CONFIG_DESCRIPTORS[module] = {
...APP_CONFIG_DESCRIPTORS[module],
...descriptors,
};
}
export function getDefaultConfig(): AppConfigSchema {
const config: Record<string, any> = {};
const envs = process.env;
for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) {
const modulizedConfig = {};
for (const [key, desc] of Object.entries(defs)) {
let defaultValue = desc.default;
if (desc.env) {
const [env, parser] = desc.env;
const envValue = envs[env];
if (envValue) {
defaultValue = parseEnvValue(envValue, parser);
}
}
const { success, error } = desc.validate(defaultValue);
if (!success) {
throw error;
}
set(modulizedConfig, key, defaultValue);
}
config[module] = modulizedConfig;
}
return config as AppConfigSchema;
} }

View File

@@ -1,127 +1,20 @@
import { Join, PathType } from '../utils/types'; import { LeafPaths, PathType } from '../utils';
export type ConfigItem<T> = T & { __type: 'ConfigItem' }; declare global {
type ConfigItem<T> = Leaf<T>;
type ConfigDef = Record<string, any> | never; interface AppConfigSchema {}
type AppConfig = DeeplyEraseLeaf<AppConfigSchema>;
export interface ModuleConfig<
Startup extends ConfigDef = never,
Runtime extends ConfigDef = never,
> {
startup: Startup;
runtime: Runtime;
} }
export type RuntimeConfigDescription<T> = { export type AppConfigByPath<Module extends keyof AppConfigSchema> =
desc: string; AppConfigSchema[Module] extends infer Config
default: T;
};
type ConfigItemLeaves<T, P extends string = ''> =
T extends Record<string, any>
? { ? {
[K in keyof T]: K extends string [Path in LeafPaths<Config>]: Path extends string
? T[K] extends { __type: 'ConfigItem' } ? PathType<Config, Path> extends infer Item
? K ? Item extends Leaf<infer V>
: T[K] extends PrimitiveType
? K
: Join<K, ConfigItemLeaves<T[K], P>>
: never;
}[keyof T]
: never;
type StartupConfigDescriptions<T extends ConfigDef> = {
[K in keyof T]: T[K] extends Record<string, any>
? T[K] extends ConfigItem<infer V>
? V ? V
: T[K] : Item
: T[K]; : never
};
type ModuleConfigLeaves<T, P extends string = ''> =
T extends Record<string, any>
? {
[K in keyof T]: K extends string
? T[K] extends ModuleConfig<any, any>
? K
: Join<K, ModuleConfigLeaves<T[K], P>>
: never;
}[keyof T]
: never;
type FlattenModuleConfigs<T extends Record<string, any>> = {
// @ts-expect-error allow
[K in ModuleConfigLeaves<T>]: PathType<T, K>;
};
type _AppStartupConfig<T extends Record<string, any>> = {
[K in keyof T]: T[K] extends ModuleConfig<infer S, any>
? S
: _AppStartupConfig<T[K]>;
};
// for extending
export interface AppConfig {}
export type AppModulesConfigDef = FlattenModuleConfigs<AppConfig>;
export type AppConfigModules = keyof AppModulesConfigDef;
export type AppStartupConfig = _AppStartupConfig<AppConfig>;
// app runtime config keyed by module names
export type AppRuntimeConfigByModules = {
[Module in keyof AppModulesConfigDef]: AppModulesConfigDef[Module] extends ModuleConfig<
any,
infer Runtime
>
? Runtime extends never
? never
: {
// @ts-expect-error allow
[K in ConfigItemLeaves<Runtime>]: PathType<
Runtime,
K
> extends infer Config
? Config extends ConfigItem<infer V>
? V
: Config
: never; : never;
} }
: never; : never;
};
// names of modules that have runtime config
export type AppRuntimeConfigModules = {
[Module in keyof AppRuntimeConfigByModules]: AppRuntimeConfigByModules[Module] extends never
? never
: Module;
}[keyof AppRuntimeConfigByModules];
// runtime config keyed by module names flattened into config names
// { auth: { allowSignup: boolean } } => { 'auth/allowSignup': boolean }
export type FlattenedAppRuntimeConfig = UnionToIntersection<
{
[Module in keyof AppRuntimeConfigByModules]: AppModulesConfigDef[Module] extends never
? never
: {
[K in keyof AppRuntimeConfigByModules[Module] as K extends string
? `${Module}/${K}`
: never]: AppRuntimeConfigByModules[Module][K];
};
}[keyof AppRuntimeConfigByModules]
>;
export type ModuleStartupConfigDescriptions<T extends ModuleConfig<any, any>> =
T extends ModuleConfig<infer S, any>
? S extends never
? undefined
: StartupConfigDescriptions<S>
: never;
export type ModuleRuntimeConfigDescriptions<
Module extends keyof AppRuntimeConfigByModules,
> = AppModulesConfigDef[Module] extends never
? never
: {
[K in keyof AppRuntimeConfigByModules[Module]]: RuntimeConfigDescription<
AppRuntimeConfigByModules[Module][K]
>;
};

View File

@@ -815,4 +815,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'action_forbidden', type: 'action_forbidden',
message: 'You can not mention yourself.', message: 'You can not mention yourself.',
}, },
// app config
invalid_app_config: {
type: 'invalid_input',
message: 'Invalid app config.',
},
} satisfies Record<string, UserFriendlyErrorOptions>; } satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -917,6 +917,12 @@ export class MentionUserOneselfDenied extends UserFriendlyError {
super('action_forbidden', 'mention_user_oneself_denied', message); super('action_forbidden', 'mention_user_oneself_denied', message);
} }
} }
export class InvalidAppConfig extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_app_config', message);
}
}
export enum ErrorNames { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
NETWORK_ERROR, NETWORK_ERROR,
@@ -1034,7 +1040,8 @@ export enum ErrorNames {
UNSUPPORTED_CLIENT_VERSION, UNSUPPORTED_CLIENT_VERSION,
NOTIFICATION_NOT_FOUND, NOTIFICATION_NOT_FOUND,
MENTION_USER_DOC_ACCESS_DENIED, MENTION_USER_DOC_ACCESS_DENIED,
MENTION_USER_ONESELF_DENIED MENTION_USER_ONESELF_DENIED,
INVALID_APP_CONFIG
} }
registerEnumType(ErrorNames, { registerEnumType(ErrorNames, {
name: 'ErrorNames' name: 'ErrorNames'

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url';
import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql'; import { Args, Query, Resolver } from '@nestjs/graphql';
import { Config } from '../config/provider';
import { generateUserFriendlyErrors } from './def'; import { generateUserFriendlyErrors } from './def';
import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen'; import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen';
@@ -23,9 +22,8 @@ class ErrorResolver {
}) })
export class ErrorModule implements OnModuleInit { export class ErrorModule implements OnModuleInit {
logger = new Logger('ErrorModule'); logger = new Logger('ErrorModule');
constructor(private readonly config: Config) {}
onModuleInit() { onModuleInit() {
if (!this.config.node.dev) { if (!env.dev) {
return; return;
} }
this.logger.log('Generating UserFriendlyError classes'); this.logger.log('Generating UserFriendlyError classes');

View File

@@ -1,6 +1,6 @@
import { OnOptions } from 'eventemitter2'; import { OnOptions } from 'eventemitter2';
import { PushMetadata, sliceMetadata } from '../nestjs'; import { PushMetadata, sliceMetadata } from '../nestjs/decorator';
declare global { declare global {
/** /**

View File

@@ -6,7 +6,10 @@ import { EventHandlerScanner } from './scanner';
const EmitProvider = { const EmitProvider = {
provide: EventEmitter2, provide: EventEmitter2,
useFactory: () => new EventEmitter2(), useFactory: () =>
new EventEmitter2({
maxListeners: 100,
}),
}; };
@Global() @Global()

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { once } from 'lodash-es'; import { once } from 'lodash-es';
import { ModuleScanner } from '../nestjs'; import { ModuleScanner } from '../nestjs/scanner';
import { import {
type EventName, type EventName,
type EventOptions, type EventOptions,

View File

@@ -1,17 +1,27 @@
import { ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriverConfig } from '@nestjs/apollo';
import { defineStartupConfig, ModuleConfig } from '../../base/config'; import { defineModuleConfig } from '../config';
declare module '../../base/config' { declare global {
interface AppConfig { interface AppConfigSchema {
graphql: ModuleConfig<ApolloDriverConfig>; graphql: {
apolloDriverConfig: ConfigItem<ApolloDriverConfig>;
};
} }
} }
defineStartupConfig('graphql', { defineModuleConfig('graphql', {
apolloDriverConfig: {
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
default: {
buildSchemaOptions: { buildSchemaOptions: {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}, },
introspection: true, useGlobalPrefix: true,
playground: true, playground: true,
introspection: true,
sortSchema: true,
},
link: 'https://docs.nestjs.com/graphql/quick-start',
},
}); });

View File

@@ -1,13 +1,12 @@
import './config'; import './config';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ApolloDriverConfig } from '@nestjs/apollo'; import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo'; import { ApolloDriver } from '@nestjs/apollo';
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql'; import { GraphQLModule } from '@nestjs/graphql';
import { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { Config } from '../config'; import { Config } from '../config';
import { mapAnyError } from '../nestjs/exception'; import { mapAnyError } from '../nestjs/exception';
@@ -26,18 +25,17 @@ export type GraphqlContext = {
driver: ApolloDriver, driver: ApolloDriver,
useFactory: (config: Config) => { useFactory: (config: Config) => {
return { return {
...config.graphql, ...config.graphql.apolloDriverConfig,
path: `${config.server.path}/graphql`, autoSchemaFile: join(
env.projectRoot,
env.testing
? './node_modules/.cache/schema.gql'
: './src/schema.gql'
),
path: '/graphql',
csrfPrevention: { csrfPrevention: {
requestHeaders: ['content-type'], requestHeaders: ['content-type'],
}, },
autoSchemaFile: join(
fileURLToPath(import.meta.url),
config.node.dev
? '../../../schema.gql'
: '../../../../node_modules/.cache/schema.gql'
),
sortSchema: true,
context: ({ context: ({
req, req,
res, res,
@@ -55,7 +53,7 @@ export type GraphqlContext = {
// @ts-expect-error allow assign // @ts-expect-error allow assign
formattedError.extensions = ufe.toJSON(); formattedError.extensions = ufe.toJSON();
if (config.affine.canary) { if (env.namespaces.canary) {
formattedError.extensions.stacktrace = ufe.stacktrace; formattedError.extensions.stacktrace = ufe.stacktrace;
} }
return formattedError; return formattedError;

View File

@@ -1,4 +1,3 @@
import { Type } from '@nestjs/common';
import { Field, FieldOptions, ObjectType } from '@nestjs/graphql'; import { Field, FieldOptions, ObjectType } from '@nestjs/graphql';
import { ApplyType } from '../utils/types'; import { ApplyType } from '../utils/types';
@@ -7,7 +6,7 @@ export function registerObjectType<T>(
fields: Record< fields: Record<
string, string,
{ {
type: () => Type<any>; type: () => any;
options?: FieldOptions; options?: FieldOptions;
} }
>, >,

View File

@@ -1,5 +1,3 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -9,42 +7,19 @@ const test = ava as TestFn<{
crypto: CryptoHelper; crypto: CryptoHelper;
}>; }>;
const key = `-----BEGIN EC PRIVATE KEY----- const privateKey = `-----BEGIN PRIVATE KEY-----
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49 MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgS3IAkshQuSmFWGpe
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI rGTg2vwaC3LdcvBQlYHHMBYJZMyhRANCAAQXdT/TAh4neNEpd4UqpDIEqWv0XvFo
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== BRJxGsC5I/fetqObdx1+KEjcm8zFU2xLaUTw9IZCu8OslloOjQv4ur0a
-----END EC PRIVATE KEY-----`; -----END PRIVATE KEY-----`;
const privateKey = createPrivateKey({
key,
format: 'pem',
type: 'sec1',
})
.export({
type: 'pkcs8',
format: 'pem',
})
.toString('utf8');
const publicKey = createPublicKey({
key,
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
test.beforeEach(async t => { test.beforeEach(async t => {
t.context.crypto = new CryptoHelper({ t.context.crypto = new CryptoHelper({
crypto: { crypto: {
secret: {
publicKey,
privateKey, privateKey,
}, },
},
} as any); } as any);
t.context.crypto.onConfigInit();
}); });
test('should be able to sign and verify', t => { test('should be able to sign and verify', t => {

View File

@@ -1,53 +1,18 @@
import { createPrivateKey, createPublicKey } from 'node:crypto'; import { defineModuleConfig } from '../config';
import { defineStartupConfig, ModuleConfig } from '../config'; declare global {
interface AppConfigSchema {
declare module '../config' { crypto: {
interface AppConfig {
crypto: ModuleConfig<{
secret: {
publicKey: string;
privateKey: string; privateKey: string;
}; };
}>;
} }
} }
// Don't use this in production defineModuleConfig('crypto', {
const examplePrivateKey = `-----BEGIN EC PRIVATE KEY----- privateKey: {
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49 desc: 'The private key for used by the crypto module to create signed tokens or encrypt data.',
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI env: 'AFFINE_PRIVATE_KEY',
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== default: '',
-----END EC PRIVATE KEY-----`; schema: { type: 'string' },
},
defineStartupConfig('crypto', {
secret: (function () {
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
return {
publicKey,
privateKey,
};
})(),
}); });

View File

@@ -2,8 +2,11 @@ import {
createCipheriv, createCipheriv,
createDecipheriv, createDecipheriv,
createHash, createHash,
createPrivateKey,
createPublicKey,
createSign, createSign,
createVerify, createVerify,
generateKeyPairSync,
randomBytes, randomBytes,
randomInt, randomInt,
timingSafeEqual, timingSafeEqual,
@@ -16,13 +19,48 @@ import {
} from '@node-rs/argon2'; } from '@node-rs/argon2';
import { Config } from '../config'; import { Config } from '../config';
import { OnEvent } from '../event';
const NONCE_LENGTH = 12; const NONCE_LENGTH = 12;
const AUTH_TAG_LENGTH = 12; const AUTH_TAG_LENGTH = 12;
function generatePrivateKey(): string {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
const key = privateKey.export({
type: 'sec1',
format: 'pem',
});
return key.toString('utf8');
}
function readPrivateKey(privateKey: string) {
return createPrivateKey({
key: Buffer.from(privateKey),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
}
function readPublicKey(privateKey: string) {
return createPublicKey({
key: Buffer.from(privateKey),
})
.export({ format: 'pem', type: 'spki' })
.toString('utf8');
}
@Injectable() @Injectable()
export class CryptoHelper { export class CryptoHelper {
keyPair: { keyPair!: {
publicKey: Buffer; publicKey: Buffer;
privateKey: Buffer; privateKey: Buffer;
sha256: { sha256: {
@@ -31,13 +69,31 @@ export class CryptoHelper {
}; };
}; };
constructor(config: Config) { constructor(private readonly config: Config) {}
@OnEvent('config.init')
onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
onConfigChanged(event: Events['config.changed']) {
if (event.updates.crypto?.privateKey) {
this.setup();
}
}
private setup() {
const key = this.config.crypto.privateKey || generatePrivateKey();
const privateKey = readPrivateKey(key);
const publicKey = readPublicKey(key);
this.keyPair = { this.keyPair = {
publicKey: Buffer.from(config.crypto.secret.publicKey, 'utf8'), publicKey: Buffer.from(publicKey),
privateKey: Buffer.from(config.crypto.secret.privateKey, 'utf8'), privateKey: Buffer.from(privateKey),
sha256: { sha256: {
publicKey: this.sha256(config.crypto.secret.publicKey), publicKey: this.sha256(publicKey),
privateKey: this.sha256(config.crypto.secret.privateKey), privateKey: this.sha256(privateKey),
}, },
}; };
} }

View File

@@ -4,16 +4,23 @@ import { Injectable } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { Config } from '../config'; import { Config } from '../config';
import { OnEvent } from '../event';
@Injectable() @Injectable()
export class URLHelper { export class URLHelper {
private readonly redirectAllowHosts: string[]; redirectAllowHosts!: string[];
readonly origin: string; origin!: string;
readonly baseUrl: string; baseUrl!: string;
readonly home: string; home!: string;
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
this.init();
}
@OnEvent('config.changed')
@OnEvent('config.init')
init() {
if (this.config.server.externalUrl) { if (this.config.server.externalUrl) {
if (!this.verify(this.config.server.externalUrl)) { if (!this.verify(this.config.server.externalUrl)) {
throw new Error( throw new Error(

View File

@@ -6,17 +6,14 @@ export {
SessionCache, SessionCache,
} from './cache'; } from './cache';
export { export {
type AFFiNEConfig,
applyEnvToConfig,
Config, Config,
type ConfigPaths, ConfigFactory,
DeploymentType, defineModuleConfig,
getAFFiNEConfigModifier, type JSONSchema,
} from './config'; } from './config';
export * from './error'; export * from './error';
export { EventBus, OnEvent } from './event'; export { EventBus, OnEvent } from './event';
export { export {
type GraphqlContext,
paginate, paginate,
Paginated, Paginated,
PaginationInput, PaginationInput,
@@ -30,8 +27,12 @@ export { CallMetric, metrics } from './metrics';
export { Lock, Locker, Mutex, RequestMutex } from './mutex'; export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export * from './nestjs'; export * from './nestjs';
export { type PrismaTransaction } from './prisma'; export { type PrismaTransaction } from './prisma';
export { Runtime } from './runtime';
export * from './storage'; export * from './storage';
export { type StorageProvider, StorageProviderFactory } from './storage'; export {
autoMetadata,
type StorageProvider,
type StorageProviderConfig,
StorageProviderFactory,
} from './storage';
export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler'; export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler';
export * from './utils'; export * from './utils';

View File

@@ -52,14 +52,16 @@ class JobHandlers {
test.before(async () => { test.before(async () => {
module = await createTestingModule({ module = await createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.override({
job: { job: {
worker: { worker: {
defaultWorkerOptions: {
// NOTE(@forehalo): // NOTE(@forehalo):
// bullmq will hold the connection to check stalled jobs, // bullmq will hold the connection to check stalled jobs,
// which will keep the test process alive to timeout. // which will keep the test process alive to timeout.
stalledInterval: 100, stalledInterval: 100,
}, },
},
queue: { queue: {
defaultJobOptions: { delay: 1000 }, defaultJobOptions: { delay: 1000 },
}, },
@@ -82,7 +84,7 @@ test.afterEach(async () => {
// @ts-expect-error private api // @ts-expect-error private api
const inner = queue.getQueue('nightly'); const inner = queue.getQueue('nightly');
await inner.obliterate({ force: true }); await inner.obliterate({ force: true });
inner.resume(); await inner.resume();
}); });
test.after.always(async () => { test.after.always(async () => {
@@ -132,7 +134,7 @@ test('should remove job from queue', async t => {
// #region executor // #region executor
test('should start workers', async t => { test('should start workers', async t => {
// @ts-expect-error private api // @ts-expect-error private api
const worker = executor.workers['nightly']; const worker = executor.workers.get('nightly')!;
t.truthy(worker); t.truthy(worker);
t.true(worker.isRunning()); t.true(worker.isRunning());

View File

@@ -1,33 +1,36 @@
import { QueueOptions, WorkerOptions } from 'bullmq'; import { QueueOptions, WorkerOptions } from 'bullmq';
import { import { defineModuleConfig, JSONSchema } from '../../config';
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../config';
import { Queue } from './def'; import { Queue } from './def';
declare module '../../config' { declare global {
interface AppConfig { interface AppConfigSchema {
job: ModuleConfig< job: {
{ queue: ConfigItem<Omit<QueueOptions, 'connection' | 'telemetry'>>;
queue: Omit<QueueOptions, 'connection'>; worker: ConfigItem<{
worker: Omit<WorkerOptions, 'connection'>; defaultWorkerOptions: Omit<WorkerOptions, 'connection' | 'telemetry'>;
}, }>;
{
queues: { queues: {
[key in Queue]: { [key in Queue]: ConfigItem<{
concurrency: number; concurrency: number;
}>;
}; };
}; };
} }
>;
}
} }
defineStartupConfig('job', { const schema: JSONSchema = {
type: 'object',
properties: {
concurrency: { type: 'number' },
},
};
defineModuleConfig('job', {
queue: { queue: {
prefix: AFFiNE.node.test ? 'affine_job_test' : 'affine_job', desc: 'The config for job queues',
default: {
prefix: env.testing ? 'affine_job_test' : 'affine_job',
defaultJobOptions: { defaultJobOptions: {
attempts: 5, attempts: 5,
// should remove job after it's completed, because we will add a new job with the same job id // should remove job after it's completed, because we will add a new job with the same job id
@@ -38,24 +41,46 @@ defineStartupConfig('job', {
}, },
}, },
}, },
worker: {}, link: 'https://api.docs.bullmq.io/interfaces/v5.QueueOptions.html',
}); },
defineRuntimeConfig('job', { worker: {
'queues.nightly.concurrency': { desc: 'The config for job workers',
default: 1, default: {
desc: 'Concurrency of worker consuming of nightly checking job queue', defaultWorkerOptions: {},
}, },
'queues.notification.concurrency': { link: 'https://api.docs.bullmq.io/interfaces/v5.WorkerOptions.html',
default: 10,
desc: 'Concurrency of worker consuming of notification job queue',
}, },
'queues.doc.concurrency': {
default: 1, 'queues.copilot': {
desc: 'Concurrency of worker consuming of doc job queue', desc: 'The config for copilot job queue',
default: {
concurrency: 1,
}, },
'queues.copilot.concurrency': { schema,
default: 1, },
desc: 'Concurrency of worker consuming of copilot job queue',
'queues.doc': {
desc: 'The config for doc job queue',
default: {
concurrency: 1,
},
schema,
},
'queues.notification': {
desc: 'The config for notification job queue',
default: {
concurrency: 10,
},
schema,
},
'queues.nightly': {
desc: 'The config for nightly job queue',
default: {
concurrency: 1,
},
schema,
}, },
}); });

View File

@@ -49,7 +49,7 @@ export const OnJob = (job: JobName) => {
if (!QUEUES.includes(ns as Queue)) { if (!QUEUES.includes(ns as Queue)) {
throw new Error( throw new Error(
`Invalid job queue: ${ns}, must be one of [${QUEUES.join(', ')}]. `Invalid job queue: ${ns}, must be one of [${QUEUES.join(', ')}].
If you want to introduce new job queue, please modify the Queue enum first in ${join(AFFiNE.projectRoot, 'src/base/job/queue/def.ts')}` If you want to introduce new job queue, please modify the Queue enum first in ${join(env.projectRoot, 'src/base/job/queue/def.ts')}`
); );
} }

View File

@@ -1,49 +1,51 @@
import { import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import { difference } from 'lodash-es'; import { difference, merge } from 'lodash-es';
import { CLS_ID, ClsServiceManager } from 'nestjs-cls'; import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
import { Config } from '../../config'; import { Config } from '../../config';
import { OnEvent } from '../../event';
import { metrics, wrapCallMetric } from '../../metrics'; import { metrics, wrapCallMetric } from '../../metrics';
import { QueueRedis } from '../../redis'; import { QueueRedis } from '../../redis';
import { Runtime } from '../../runtime';
import { genRequestId } from '../../utils'; import { genRequestId } from '../../utils';
import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def'; import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def';
import { JobHandlerScanner } from './scanner'; import { JobHandlerScanner } from './scanner';
@Injectable() @Injectable()
export class JobExecutor export class JobExecutor implements OnModuleDestroy {
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger('job'); private readonly logger = new Logger('job');
private readonly workers: Record<string, Worker> = {}; private readonly workers: Map<Queue, Worker> = new Map();
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly redis: QueueRedis, private readonly redis: QueueRedis,
private readonly scanner: JobHandlerScanner, private readonly scanner: JobHandlerScanner
private readonly runtime: Runtime
) {} ) {}
async onApplicationBootstrap() { @OnEvent('config.init')
const queues = this.config.flavor.graphql async onConfigInit() {
? difference(QUEUES, [Queue.DOC]) const queues = env.flavors.graphql ? difference(QUEUES, [Queue.DOC]) : [];
: [];
// NOTE(@forehalo): only enable doc queue in doc service // NOTE(@forehalo): only enable doc queue in doc service
if (this.config.flavor.doc) { if (env.flavors.doc) {
queues.push(Queue.DOC); queues.push(Queue.DOC);
} }
await this.startWorkers(queues); await this.startWorkers(queues);
} }
async onApplicationShutdown() { @OnEvent('config.changed')
async onConfigChanged({ updates }: Events['config.changed']) {
if (updates.job?.queues) {
Object.entries(updates.job.queues).forEach(([queue, options]) => {
if (options.concurrency) {
this.setConcurrency(queue as Queue, options.concurrency);
}
});
}
}
async onModuleDestroy() {
await this.stopWorkers(); await this.stopWorkers();
} }
@@ -98,38 +100,35 @@ export class JobExecutor
} }
} }
private async startWorkers(queues: Queue[]) { setConcurrency(queue: Queue, concurrency: number) {
const configs = const worker = this.workers.get(queue);
(await this.runtime.fetchAll( if (!worker) {
queues.reduce( throw new Error(`Worker for [${queue}] not found.`);
(ret, queue) => {
ret[`job/queues.${queue}.concurrency`] = true;
return ret;
},
{} as {
[key in `job/queues.${Queue}.concurrency`]: true;
} }
)
// TODO(@forehalo): fix the override by [payment/service.spec.ts]
)) ?? {};
worker.concurrency = concurrency;
}
private async startWorkers(queues: Queue[]) {
for (const queue of queues) { for (const queue of queues) {
const concurrency = const queueOptions = this.config.job.queues[queue];
(configs[`job/queues.${queue}.concurrency`] as number) ?? const concurrency = queueOptions.concurrency ?? 1;
this.config.job.worker.concurrency ??
1;
const worker = new Worker( const worker = new Worker(
queue, queue,
async job => { async job => {
await this.run(job.name as JobName, job.data); await this.run(job.name as JobName, job.data);
}, },
merge(
{},
this.config.job.queue,
this.config.job.worker.defaultWorkerOptions,
queueOptions,
{ {
...this.config.job.queue,
...this.config.job.worker,
connection: this.redis,
concurrency, concurrency,
connection: this.redis,
} }
)
); );
worker.on('error', error => { worker.on('error', error => {
@@ -140,13 +139,13 @@ export class JobExecutor
`Queue Worker [${queue}] started; concurrency=${concurrency};` `Queue Worker [${queue}] started; concurrency=${concurrency};`
); );
this.workers[queue] = worker; this.workers.set(queue, worker);
} }
} }
private async stopWorkers() { private async stopWorkers() {
await Promise.all( await Promise.all(
Object.values(this.workers).map(async worker => { Array.from(this.workers.values()).map(async worker => {
await worker.close(true); await worker.close(true);
}) })
); );

View File

@@ -1,33 +1,16 @@
import { defineStartupConfig, ModuleConfig } from '../config'; import { defineModuleConfig } from '../config';
declare module '../config' { declare global {
interface AppConfig { interface AppConfigSchema {
metrics: ModuleConfig<{ metrics: {
/**
* Enable metric and tracing collection
*/
enabled: boolean; enabled: boolean;
/**
* Enable telemetry
*/
telemetry: {
enabled: boolean;
token: string;
}; };
customerIo: {
token: string;
};
}>;
} }
} }
defineStartupConfig('metrics', { defineModuleConfig('metrics', {
enabled: false, enabled: {
telemetry: { desc: 'Enable metric and tracing collection',
enabled: false, default: false,
token: '',
},
customerIo: {
token: '',
}, },
}); });

View File

@@ -1,54 +1,14 @@
import './config'; import './config';
import { import { Global, Module } from '@nestjs/common';
Global,
Module,
OnModuleDestroy,
OnModuleInit,
Provider,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { Config } from '../config'; import { OpentelemetryFactory } from './opentelemetry';
import {
LocalOpentelemetryFactory,
OpentelemetryFactory,
registerCustomMetrics,
} from './opentelemetry';
const factorProvider: Provider = {
provide: OpentelemetryFactory,
useFactory: (config: Config) => {
return config.metrics.enabled ? new LocalOpentelemetryFactory() : null;
},
inject: [Config],
};
@Global() @Global()
@Module({ @Module({
providers: [factorProvider], providers: [OpentelemetryFactory],
exports: [factorProvider],
}) })
export class MetricsModule implements OnModuleInit, OnModuleDestroy { export class MetricsModule {}
private sdk: NodeSDK | null = null;
constructor(private readonly ref: ModuleRef) {}
onModuleInit() {
const factor = this.ref.get(OpentelemetryFactory, { strict: false });
if (factor) {
this.sdk = factor.create();
this.sdk.start();
registerCustomMetrics();
}
}
async onModuleDestroy() {
if (this.sdk) {
await this.sdk.shutdown();
}
}
}
export * from './metrics'; export * from './metrics';
export * from './utils'; export * from './utils';

View File

@@ -2,11 +2,28 @@ import {
Gauge, Gauge,
Histogram, Histogram,
Meter, Meter,
MeterProvider,
MetricOptions, MetricOptions,
metrics as otelMetrics,
UpDownCounter, UpDownCounter,
} from '@opentelemetry/api'; } from '@opentelemetry/api';
import { HostMetrics } from '@opentelemetry/host-metrics';
import { getMeter } from './opentelemetry'; function getMeterProvider() {
return otelMetrics.getMeterProvider();
}
export function registerCustomMetrics() {
const hostMetricsMonitoring = new HostMetrics({
name: 'instance-host-metrics',
meterProvider: getMeterProvider() as MeterProvider,
});
hostMetricsMonitoring.start();
}
export function getMeter(name = 'business') {
return getMeterProvider().getMeter(name);
}
type MetricType = 'counter' | 'gauge' | 'histogram'; type MetricType = 'counter' | 'gauge' | 'histogram';
type Metric<T extends MetricType> = T extends 'counter' type Metric<T extends MetricType> = T extends 'counter'
@@ -122,5 +139,3 @@ export const metrics = new Proxy<Record<KnownMetricScopes, ScopedMetrics>>(
}, },
} }
); );
export function stopMetrics() {}

View File

@@ -1,5 +1,4 @@
import { OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { metrics } from '@opentelemetry/api';
import { import {
CompositePropagator, CompositePropagator,
W3CBaggagePropagator, W3CBaggagePropagator,
@@ -7,7 +6,6 @@ import {
} from '@opentelemetry/core'; } from '@opentelemetry/core';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin'; import { ZipkinExporter } from '@opentelemetry/exporter-zipkin';
import { HostMetrics } from '@opentelemetry/host-metrics';
import { Instrumentation } from '@opentelemetry/instrumentation'; import { Instrumentation } from '@opentelemetry/instrumentation';
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
@@ -15,7 +13,6 @@ import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io'; import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import { Resource } from '@opentelemetry/resources'; import { Resource } from '@opentelemetry/resources';
import type { MeterProvider } from '@opentelemetry/sdk-metrics';
import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics'; import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node'; import { NodeSDK } from '@opentelemetry/sdk-node';
import { import {
@@ -30,11 +27,14 @@ import {
} from '@opentelemetry/semantic-conventions/incubating'; } from '@opentelemetry/semantic-conventions/incubating';
import prismaInstrument from '@prisma/instrumentation'; import prismaInstrument from '@prisma/instrumentation';
import { Config } from '../config';
import { OnEvent } from '../event/def';
import { registerCustomMetrics } from './metrics';
import { PrismaMetricProducer } from './prisma'; import { PrismaMetricProducer } from './prisma';
const { PrismaInstrumentation } = prismaInstrument; const { PrismaInstrumentation } = prismaInstrument;
export abstract class OpentelemetryFactory { export abstract class BaseOpentelemetryFactory {
abstract getMetricReader(): MetricReader; abstract getMetricReader(): MetricReader;
abstract getSpanExporter(): SpanExporter; abstract getSpanExporter(): SpanExporter;
@@ -55,9 +55,9 @@ export abstract class OpentelemetryFactory {
getResource() { getResource() {
return new Resource({ return new Resource({
[ATTR_K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV, [ATTR_K8S_NAMESPACE_NAME]: env.NAMESPACE,
[ATTR_SERVICE_NAME]: AFFiNE.flavor.type, [ATTR_SERVICE_NAME]: env.FLAVOR,
[ATTR_SERVICE_VERSION]: AFFiNE.version, [ATTR_SERVICE_VERSION]: env.version,
}); });
} }
@@ -81,39 +81,58 @@ export abstract class OpentelemetryFactory {
} }
} }
export class LocalOpentelemetryFactory @Injectable()
extends OpentelemetryFactory export class OpentelemetryFactory
extends BaseOpentelemetryFactory
implements OnModuleDestroy implements OnModuleDestroy
{ {
private readonly metricsExporter = new PrometheusExporter({ private readonly logger = new Logger(OpentelemetryFactory.name);
metricProducers: this.getMetricsProducers(), #sdk: NodeSDK | null = null;
});
constructor(private readonly config: Config) {
super();
}
@OnEvent('config.init')
async init(event: Events['config.init']) {
if (event.config.metrics.enabled) {
await this.setup();
registerCustomMetrics();
}
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('metrics' in event.updates) {
await this.setup();
}
}
async onModuleDestroy() { async onModuleDestroy() {
await this.metricsExporter.shutdown(); await this.#sdk?.shutdown();
} }
override getMetricReader(): MetricReader { override getMetricReader(): MetricReader {
return this.metricsExporter; return new PrometheusExporter({
metricProducers: this.getMetricsProducers(),
});
} }
override getSpanExporter(): SpanExporter { override getSpanExporter(): SpanExporter {
return new ZipkinExporter(); return new ZipkinExporter();
} }
}
function getMeterProvider() { private async setup() {
return metrics.getMeterProvider(); if (this.config.metrics.enabled) {
if (!this.#sdk) {
this.#sdk = this.create();
}
this.#sdk.start();
this.logger.log('OpenTelemetry SDK started');
} else {
await this.#sdk?.shutdown();
this.#sdk = null;
this.logger.log('OpenTelemetry SDK stopped');
} }
export function registerCustomMetrics() {
const hostMetricsMonitoring = new HostMetrics({
name: 'instance-host-metrics',
meterProvider: getMeterProvider() as MeterProvider,
});
hostMetricsMonitoring.start();
} }
export function getMeter(name = 'business') {
return getMeterProvider().getMeter(name);
} }

View File

@@ -10,7 +10,7 @@ import {
ScopeMetrics, ScopeMetrics,
} from '@opentelemetry/sdk-metrics'; } from '@opentelemetry/sdk-metrics';
import { PrismaService } from '../prisma'; import { PrismaFactory } from '../prisma/factory';
function transformPrismaKey(key: string) { function transformPrismaKey(key: string) {
// replace first '_' to '/' as a scope prefix // replace first '_' to '/' as a scope prefix
@@ -30,11 +30,11 @@ export class PrismaMetricProducer implements MetricProducer {
errors: [], errors: [],
}; };
if (!PrismaService.INSTANCE) { if (!PrismaFactory.INSTANCE) {
return result; return result;
} }
const prisma = PrismaService.INSTANCE; const prisma = PrismaFactory.INSTANCE;
const endTime = hrTime(); const endTime = hrTime();

View File

@@ -1,39 +0,0 @@
import { defineStartupConfig, ModuleConfig } from '../../base/config';
export interface ServerStartupConfigurations {
/**
* Base url of AFFiNE server, used for generating external urls.
* default to be `[AFFiNE.protocol]://[AFFiNE.host][:AFFiNE.port]/[AFFiNE.path]` if not specified
*/
externalUrl: string;
/**
* Whether the server is hosted on a ssl enabled domain
*/
https: boolean;
/**
* where the server get deployed(FQDN).
*/
host: string;
/**
* which port the server will listen on
*/
port: number;
/**
* subpath where the server get deployed if there is.
*/
path: string;
}
declare module '../../base/config' {
interface AppConfig {
server: ModuleConfig<ServerStartupConfigurations>;
}
}
defineStartupConfig('server', {
externalUrl: '',
https: false,
host: 'localhost',
port: 3010,
path: '',
});

View File

@@ -1,5 +1,3 @@
import './config';
export * from './decorator'; export * from './decorator';
export * from './exception'; export * from './exception';
export * from './optional-module';
export * from './scanner'; export * from './scanner';

View File

@@ -1,72 +0,0 @@
import {
DynamicModule,
Module,
ModuleMetadata,
Provider,
Type,
} from '@nestjs/common';
import { omit } from 'lodash-es';
import type { AFFiNEConfig, ConfigPaths } from '../config';
export interface OptionalModuleMetadata extends ModuleMetadata {
/**
* Only install module if given config paths are defined in AFFiNE config.
*/
requires?: ConfigPaths[];
/**
* Only install module if the predication returns true.
*/
if?: (config: AFFiNEConfig) => boolean;
/**
* Defines which feature will be enabled if the module installed.
*/
contributesTo?: import('../../core/config').ServerFeature; // avoid circlar dependency
/**
* Defines which providers provided by other modules will be overridden if the module installed.
*/
overrides?: Provider[];
}
const additionalOptions = [
'contributesTo',
'requires',
'if',
'overrides',
] as const satisfies Array<keyof OptionalModuleMetadata>;
type OptionalDynamicModule = DynamicModule & OptionalModuleMetadata;
export function OptionalModule(metadata: OptionalModuleMetadata) {
return (target: Type) => {
additionalOptions.forEach(option => {
if (Object.hasOwn(metadata, option)) {
Reflect.defineMetadata(option, metadata[option], target);
}
});
if (metadata.overrides) {
metadata.providers = (metadata.providers ?? []).concat(
metadata.overrides
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
metadata.exports = (metadata.exports ?? []).concat(metadata.overrides);
}
const nestMetadata = omit(metadata, additionalOptions);
Module(nestMetadata)(target);
};
}
export function getOptionalModuleMetadata<
T extends keyof OptionalModuleMetadata,
>(target: Type | OptionalDynamicModule, key: T): OptionalModuleMetadata[T] {
if ('module' in target) {
return target[key];
} else {
return Reflect.getMetadata(key, target);
}
}

View File

@@ -1,17 +1,27 @@
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { z } from 'zod';
import { defineStartupConfig, ModuleConfig } from '../config'; import { defineModuleConfig } from '../config';
interface PrismaStartupConfiguration extends Prisma.PrismaClientOptions { declare global {
interface AppConfigSchema {
db: {
datasourceUrl: string; datasourceUrl: string;
} prisma: ConfigItem<Prisma.PrismaClientOptions>;
};
declare module '../config' {
interface AppConfig {
prisma: ModuleConfig<PrismaStartupConfiguration>;
} }
} }
defineStartupConfig('prisma', { defineModuleConfig('db', {
datasourceUrl: '', datasourceUrl: {
desc: 'The datasource url for the prisma client.',
default: 'postgresql://localhost:5432/affine',
env: 'DATABASE_URL',
shape: z.string().url(),
},
prisma: {
desc: 'The config for the prisma client.',
default: {},
link: 'https://www.prisma.io/docs/reference/api-reference/prisma-client-reference',
},
}); });

View File

@@ -0,0 +1,25 @@
import type { OnModuleDestroy } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Config } from '../config';
@Injectable()
export class PrismaFactory implements OnModuleDestroy {
static INSTANCE: PrismaClient | null = null;
readonly #instance: PrismaClient;
constructor(config: Config) {
this.#instance = new PrismaClient(config.db.prisma);
PrismaFactory.INSTANCE = this.#instance;
}
get() {
return this.#instance;
}
async onModuleDestroy() {
await PrismaFactory.INSTANCE?.$disconnect();
PrismaFactory.INSTANCE = null;
}
}

View File

@@ -3,29 +3,24 @@ import './config';
import { Global, Module, Provider } from '@nestjs/common'; import { Global, Module, Provider } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { Config } from '../config'; import { PrismaFactory } from './factory';
import { PrismaService } from './service';
// only `PrismaClient` can be injected // only `PrismaClient` can be injected
const clientProvider: Provider = { const clientProvider: Provider = {
provide: PrismaClient, provide: PrismaClient,
useFactory: (config: Config) => { useFactory: (factory: PrismaFactory) => {
if (PrismaService.INSTANCE) { return factory.get();
return PrismaService.INSTANCE;
}
return new PrismaService(config.prisma);
}, },
inject: [Config], inject: [PrismaFactory],
}; };
@Global() @Global()
@Module({ @Module({
providers: [clientProvider], providers: [PrismaFactory, clientProvider],
exports: [clientProvider], exports: [clientProvider],
}) })
export class PrismaModule {} export class PrismaModule {}
export { PrismaService } from './service'; export { PrismaFactory };
export type PrismaTransaction = Parameters< export type PrismaTransaction = Parameters<
Parameters<PrismaClient['$transaction']>[0] Parameters<PrismaClient['$transaction']>[0]

View File

@@ -1,27 +0,0 @@
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnApplicationShutdown
{
static INSTANCE: PrismaService | null = null;
constructor(opts: Prisma.PrismaClientOptions) {
super(opts);
PrismaService.INSTANCE = this;
}
async onModuleInit() {
await this.$connect();
}
async onApplicationShutdown(): Promise<void> {
if (!AFFiNE.node.test) {
await this.$disconnect();
PrismaService.INSTANCE = null;
}
}
}

View File

@@ -1,11 +1,54 @@
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { z } from 'zod';
import { defineStartupConfig, ModuleConfig } from '../../base/config'; import { defineModuleConfig } from '../config';
declare module '../config' { declare global {
interface AppConfig { interface AppConfigSchema {
redis: ModuleConfig<RedisOptions>; redis: {
host: string;
port: number;
db: number;
username: string;
password: string;
ioredis: ConfigItem<
Omit<RedisOptions, 'host' | 'port' | 'db' | 'username' | 'password'>
>;
};
} }
} }
defineStartupConfig('redis', {}); defineModuleConfig('redis', {
db: {
desc: 'The database index of redis server to be used(Must be less than 10).',
default: 0,
env: ['REDIS_DATABASE', 'integer'],
validate: val => val >= 0 && val < 10,
},
host: {
desc: 'The host of the redis server.',
default: 'localhost',
env: ['REDIS_HOST', 'string'],
},
port: {
desc: 'The port of the redis server.',
default: 6379,
env: ['REDIS_PORT', 'integer'],
shape: z.number().positive(),
},
username: {
desc: 'The username of the redis server.',
default: '',
env: ['REDIS_USERNAME', 'string'],
},
password: {
desc: 'The password of the redis server.',
default: '',
env: ['REDIS_PASSWORD', 'string'],
},
ioredis: {
desc: 'The config for the ioredis client.',
default: {},
link: 'https://github.com/luin/ioredis',
},
});

Some files were not shown because too many files have changed in this diff Show More