mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
refactor(server): config system (#11081)
This commit is contained in:
900
.docker/selfhost/schema.json
Normal file
900
.docker/selfhost/schema.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
.github/actions/deploy/deploy.mjs
vendored
57
.github/actions/deploy/deploy.mjs
vendored
@@ -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,
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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 }}"
|
|
||||||
45
.github/helm/affine/charts/graphql/values.yaml
vendored
45
.github/helm/affine/charts/graphql/values.yaml
vendored
@@ -10,55 +10,12 @@ 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
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
.github/helm/affine/templates/r2-secret.yaml
vendored
11
.github/helm/affine/templates/r2-secret.yaml
vendored
@@ -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 }}
|
|
||||||
23
.github/helm/affine/values.yaml
vendored
23
.github/helm/affine/values.yaml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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",
|
||||||
|
|||||||
1767
packages/backend/server/schema.gql
Normal file
1767
packages/backend/server/schema.gql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,23 +24,25 @@ model User {
|
|||||||
registered Boolean @default(true)
|
registered Boolean @default(true)
|
||||||
disabled Boolean @default(false)
|
disabled Boolean @default(false)
|
||||||
|
|
||||||
features UserFeature[]
|
features UserFeature[]
|
||||||
userStripeCustomer UserStripeCustomer?
|
userStripeCustomer UserStripeCustomer?
|
||||||
workspacePermissions WorkspaceUserRole[]
|
workspacePermissions WorkspaceUserRole[]
|
||||||
docPermissions WorkspaceDocUserRole[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
aiSessions AiSession[]
|
aiSessions AiSession[]
|
||||||
updatedRuntimeConfigs RuntimeConfig[]
|
/// @deprecated
|
||||||
userSnapshots UserSnapshot[]
|
deprecatedAppRuntimeSettings DeprecatedAppRuntimeSettings[]
|
||||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
appConfigs AppConfig[]
|
||||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
userSnapshots UserSnapshot[]
|
||||||
createdUpdate Update[] @relation("createdUpdate")
|
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
createdUpdate Update[] @relation("createdUpdate")
|
||||||
|
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||||
|
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||||
// receive notifications
|
// receive notifications
|
||||||
notifications Notification[] @relation("user_notifications")
|
notifications Notification[] @relation("user_notifications")
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -438,12 +440,12 @@ model AiContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AiContextEmbedding {
|
model AiContextEmbedding {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
contextId String @map("context_id") @db.VarChar
|
contextId String @map("context_id") @db.VarChar
|
||||||
fileId String @map("file_id") @db.VarChar
|
fileId String @map("file_id") @db.VarChar
|
||||||
// a file can be divided into multiple chunks and embedded separately.
|
// a file can be divided into multiple chunks and embedded separately.
|
||||||
chunk Int @db.Integer
|
chunk Int @db.Integer
|
||||||
content String @db.VarChar
|
content String @db.VarChar
|
||||||
embedding Unsupported("vector(1024)")
|
embedding Unsupported("vector(1024)")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
@@ -457,11 +459,11 @@ model AiContextEmbedding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AiWorkspaceEmbedding {
|
model AiWorkspaceEmbedding {
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
docId String @map("doc_id") @db.VarChar
|
docId String @map("doc_id") @db.VarChar
|
||||||
// a doc can be divided into multiple chunks and embedded separately.
|
// a doc can be divided into multiple chunks and embedded separately.
|
||||||
chunk Int @db.Integer
|
chunk Int @db.Integer
|
||||||
content String @db.VarChar
|
content String @db.VarChar
|
||||||
embedding Unsupported("vector(1024)")
|
embedding Unsupported("vector(1024)")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
@@ -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
|
||||||
|
|||||||
101
packages/backend/server/scripts/genconfig.ts
Normal file
101
packages/backend/server/scripts/genconfig.ts
Normal 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();
|
||||||
@@ -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}].`);
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: {
|
providers: {
|
||||||
openai: {
|
openai: { apiKey: '1' },
|
||||||
apiKey: '1',
|
fal: {},
|
||||||
},
|
perplexity: {},
|
||||||
fal: {
|
},
|
||||||
apiKey: '1',
|
unsplash: {
|
||||||
},
|
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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 => {
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
156
packages/backend/server/src/__tests__/env.spec.ts
Normal file
156
packages/backend/server/src/__tests__/env.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
113
packages/backend/server/src/__tests__/mocks/copilot.mock.ts
Normal file
113
packages/backend/server/src/__tests__/mocks/copilot.mock.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/backend/server/src/__tests__/mocks/eventbus.mock.ts
Normal file
35
packages/backend/server/src/__tests__/mocks/eventbus.mock.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,11 +87,13 @@ 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: {
|
||||||
default: {
|
throttlers: {
|
||||||
ttl: 60,
|
default: {
|
||||||
limit: 120,
|
ttl: 60,
|
||||||
|
limit: 120,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,14 +28,12 @@ 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: {
|
clientId: 'google-client-id',
|
||||||
clientId: 'google-client-id',
|
clientSecret: 'google-client-secret',
|
||||||
clientSecret: 'google-client-secret',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,16 +184,12 @@ 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: {
|
enabled: true,
|
||||||
stripe: {
|
showLifetimePrice: true,
|
||||||
keys: {
|
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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const gql = '/graphql';
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
this.modules.push(m);
|
||||||
if (result) {
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
3
packages/backend/server/src/base/config/config.ts
Normal file
3
packages/backend/server/src/base/config/config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ApplyType } from '../utils';
|
||||||
|
|
||||||
|
export class Config extends ApplyType<AppConfig>() {}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
60
packages/backend/server/src/base/config/factory.ts
Normal file
60
packages/backend/server/src/base/config/factory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>() {}
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'array';
|
||||||
|
items?: JSONSchema;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'object';
|
||||||
|
properties?: Record<string, JSONSchema>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export function runtimeConfigType(val: any): RuntimeConfigType {
|
type ConfigType = EnvConfigType | 'array' | 'object' | 'any';
|
||||||
if (Array.isArray(val)) {
|
export type ConfigDescriptor<T> = {
|
||||||
return RuntimeConfigType.Array;
|
desc: string;
|
||||||
}
|
type: ConfigType;
|
||||||
|
validate: (value: T) => z.SafeParseReturnType<T, T>;
|
||||||
|
schema: JSONSchema;
|
||||||
|
default: T;
|
||||||
|
env?: [string, EnvConfigType];
|
||||||
|
link?: string;
|
||||||
|
};
|
||||||
|
|
||||||
switch (typeof val) {
|
type ConfigDefineDescriptor<T> = {
|
||||||
case 'string':
|
desc: string;
|
||||||
return RuntimeConfigType.String;
|
default: T;
|
||||||
case 'number':
|
validate?: (value: T) => boolean;
|
||||||
return RuntimeConfigType.Number;
|
shape?: z.ZodType<T>;
|
||||||
case 'boolean':
|
env?: string | [string, EnvConfigType];
|
||||||
return RuntimeConfigType.Boolean;
|
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:
|
default:
|
||||||
return RuntimeConfigType.Object;
|
return 'any';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerRuntimeConfig<T extends keyof AppModulesConfigDef>(
|
function shapeFromType(type: ConfigType): z.ZodType<any> {
|
||||||
module: T,
|
switch (type) {
|
||||||
configs: ModuleRuntimeConfigDescriptions<T>
|
case 'string':
|
||||||
) {
|
return z.string();
|
||||||
Object.entries(configs).forEach(([key, value]) => {
|
case 'float':
|
||||||
defaultRuntimeConfig[`${module}/${key}`] = {
|
return z.number();
|
||||||
id: `${module}/${key}`,
|
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,
|
module,
|
||||||
key,
|
descriptors: Object.entries(descriptors).map(([key, descriptor]) => ({
|
||||||
description: value.desc,
|
key,
|
||||||
value: value.default,
|
descriptor,
|
||||||
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)
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 defineRuntimeConfig<T extends keyof AppModulesConfigDef>(
|
export function getDefaultConfig(): AppConfigSchema {
|
||||||
module: T,
|
const config: Record<string, any> = {};
|
||||||
configs: ModuleRuntimeConfigDescriptions<T>
|
const envs = process.env;
|
||||||
) {
|
|
||||||
registerRuntimeConfig(module, configs);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
: T[K]
|
|
||||||
: T[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
? V
|
||||||
: Config
|
: Item
|
||||||
: never;
|
: 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]
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', {
|
||||||
buildSchemaOptions: {
|
apolloDriverConfig: {
|
||||||
numberScalarMode: 'integer',
|
desc: 'The config for underlying nestjs GraphQL and apollo driver engine.',
|
||||||
|
default: {
|
||||||
|
buildSchemaOptions: {
|
||||||
|
numberScalarMode: 'integer',
|
||||||
|
},
|
||||||
|
useGlobalPrefix: true,
|
||||||
|
playground: true,
|
||||||
|
introspection: true,
|
||||||
|
sortSchema: true,
|
||||||
|
},
|
||||||
|
link: 'https://docs.nestjs.com/graphql/quick-start',
|
||||||
},
|
},
|
||||||
introspection: true,
|
|
||||||
playground: true,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
>,
|
>,
|
||||||
|
|||||||
@@ -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: {
|
privateKey,
|
||||||
publicKey,
|
|
||||||
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 => {
|
||||||
|
|||||||
@@ -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 {
|
privateKey: string;
|
||||||
crypto: ModuleConfig<{
|
};
|
||||||
secret: {
|
|
||||||
publicKey: 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,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ class JobHandlers {
|
|||||||
test.before(async () => {
|
test.before(async () => {
|
||||||
module = await createTestingModule({
|
module = await createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.override({
|
||||||
job: {
|
job: {
|
||||||
worker: {
|
worker: {
|
||||||
// NOTE(@forehalo):
|
defaultWorkerOptions: {
|
||||||
// bullmq will hold the connection to check stalled jobs,
|
// NOTE(@forehalo):
|
||||||
// which will keep the test process alive to timeout.
|
// bullmq will hold the connection to check stalled jobs,
|
||||||
stalledInterval: 100,
|
// which will keep the test process alive to timeout.
|
||||||
|
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());
|
||||||
|
|||||||
@@ -1,61 +1,86 @@
|
|||||||
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]: ConfigItem<{
|
||||||
[key in Queue]: {
|
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',
|
||||||
defaultJobOptions: {
|
default: {
|
||||||
attempts: 5,
|
prefix: env.testing ? 'affine_job_test' : 'affine_job',
|
||||||
// should remove job after it's completed, because we will add a new job with the same job id
|
defaultJobOptions: {
|
||||||
removeOnComplete: true,
|
attempts: 5,
|
||||||
removeOnFail: {
|
// should remove job after it's completed, because we will add a new job with the same job id
|
||||||
age: 24 * 3600 /* 1 day */,
|
removeOnComplete: true,
|
||||||
count: 500,
|
removeOnFail: {
|
||||||
|
age: 24 * 3600 /* 1 day */,
|
||||||
|
count: 500,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
link: 'https://api.docs.bullmq.io/interfaces/v5.QueueOptions.html',
|
||||||
},
|
},
|
||||||
worker: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
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: {},
|
||||||
|
},
|
||||||
|
link: 'https://api.docs.bullmq.io/interfaces/v5.WorkerOptions.html',
|
||||||
},
|
},
|
||||||
'queues.notification.concurrency': {
|
|
||||||
default: 10,
|
'queues.copilot': {
|
||||||
desc: 'Concurrency of worker consuming of notification job queue',
|
desc: 'The config for copilot job queue',
|
||||||
|
default: {
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
schema,
|
||||||
},
|
},
|
||||||
'queues.doc.concurrency': {
|
|
||||||
default: 1,
|
'queues.doc': {
|
||||||
desc: 'Concurrency of worker consuming of doc job queue',
|
desc: 'The config for doc job queue',
|
||||||
|
default: {
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
schema,
|
||||||
},
|
},
|
||||||
'queues.copilot.concurrency': {
|
|
||||||
default: 1,
|
'queues.notification': {
|
||||||
desc: 'Concurrency of worker consuming of copilot job queue',
|
desc: 'The config for notification job queue',
|
||||||
|
default: {
|
||||||
|
concurrency: 10,
|
||||||
|
},
|
||||||
|
schema,
|
||||||
|
},
|
||||||
|
|
||||||
|
'queues.nightly': {
|
||||||
|
desc: 'The config for nightly job queue',
|
||||||
|
default: {
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
schema,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
this.config.job.queue,
|
||||||
connection: this.redis,
|
this.config.job.worker.defaultWorkerOptions,
|
||||||
concurrency,
|
queueOptions,
|
||||||
}
|
{
|
||||||
|
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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: '',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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() {}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
export function registerCustomMetrics() {
|
}
|
||||||
const hostMetricsMonitoring = new HostMetrics({
|
this.#sdk.start();
|
||||||
name: 'instance-host-metrics',
|
this.logger.log('OpenTelemetry SDK started');
|
||||||
meterProvider: getMeterProvider() as MeterProvider,
|
} else {
|
||||||
});
|
await this.#sdk?.shutdown();
|
||||||
hostMetricsMonitoring.start();
|
this.#sdk = null;
|
||||||
}
|
this.logger.log('OpenTelemetry SDK stopped');
|
||||||
|
}
|
||||||
export function getMeter(name = 'business') {
|
}
|
||||||
return getMeterProvider().getMeter(name);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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: '',
|
|
||||||
});
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
datasourceUrl: string;
|
interface AppConfigSchema {
|
||||||
}
|
db: {
|
||||||
|
datasourceUrl: string;
|
||||||
declare module '../config' {
|
prisma: ConfigItem<Prisma.PrismaClientOptions>;
|
||||||
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
25
packages/backend/server/src/base/prisma/factory.ts
Normal file
25
packages/backend/server/src/base/prisma/factory.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user