Compare commits

..

2 Commits

Author SHA1 Message Date
DarkSky
2ac9158f87 Merge branch 'canary' into darksky/native-sync-state 2026-01-04 00:27:17 +08:00
DarkSky
7ef550a736 feat: native record encoding 2025-12-31 12:47:34 +08:00
827 changed files with 17144 additions and 37032 deletions

View File

@@ -337,42 +337,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -382,9 +348,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
}
@@ -406,42 +369,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -451,9 +380,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
},
@@ -532,42 +458,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -577,9 +469,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
}
@@ -601,42 +490,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -646,9 +501,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
},
@@ -743,11 +595,6 @@
"description": "Multiple hosts the server will accept requests from.\n@default []",
"default": []
},
"listenAddr": {
"type": "string",
"description": "The address to listen on (e.g., 0.0.0.0 for IPv4, :: for IPv6).\n@default \"0.0.0.0\"\n@environment `LISTEN_ADDR`",
"default": "0.0.0.0"
},
"port": {
"type": "number",
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
@@ -782,45 +629,6 @@
}
}
},
"telemetry": {
"type": "object",
"description": "Configuration for telemetry module",
"properties": {
"allowedOrigin": {
"type": "array",
"description": "Allowed origins for telemetry collection.\n@default [\"localhost\",\"127.0.0.1\"]",
"default": [
"localhost",
"127.0.0.1"
]
},
"ga4.measurementId": {
"type": "string",
"description": "GA4 Measurement ID for Measurement Protocol.\n@default \"\"\n@environment `GA4_MEASUREMENT_ID`",
"default": ""
},
"ga4.apiSecret": {
"type": "string",
"description": "GA4 API secret for Measurement Protocol.\n@default \"\"\n@environment `GA4_API_SECRET`",
"default": ""
},
"dedupe.ttlHours": {
"type": "number",
"description": "Telemetry dedupe TTL in hours.\n@default 24",
"default": 24
},
"dedupe.maxEntries": {
"type": "number",
"description": "Telemetry dedupe max entries.\n@default 100000",
"default": 100000
},
"batch.maxEvents": {
"type": "number",
"description": "Max events per telemetry batch.\n@default 25",
"default": 25
}
}
},
"client": {
"type": "object",
"description": "Configuration for client module",
@@ -832,108 +640,8 @@
},
"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.25.0\"",
"default": ">=0.25.0"
}
}
},
"calendar": {
"type": "object",
"description": "Configuration for calendar module",
"properties": {
"google": {
"type": "object",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
"properties": {
"enabled": {
"type": "boolean"
},
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"externalWebhookUrl": {
"type": "string"
},
"webhookVerificationToken": {
"type": "string"
}
},
"default": {
"enabled": false,
"clientId": "",
"clientSecret": "",
"externalWebhookUrl": "",
"webhookVerificationToken": ""
}
},
"caldav": {
"type": "object",
"description": "CalDAV integration config\n@default {\"enabled\":false,\"allowCustomProvider\":false,\"providers\":[],\"allowInsecureHttp\":false,\"allowedHosts\":[],\"blockPrivateNetwork\":true,\"requestTimeoutMs\":10000,\"maxRedirects\":5}",
"properties": {
"enabled": {
"type": "boolean"
},
"allowCustomProvider": {
"type": "boolean"
},
"providers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"serverUrl": {
"type": "string"
},
"authType": {
"type": "string"
},
"requiresAppPassword": {
"type": "boolean"
},
"docsUrl": {
"type": "string"
}
}
}
},
"allowInsecureHttp": {
"type": "boolean"
},
"allowedHosts": {
"type": "array",
"items": {
"type": "string"
}
},
"blockPrivateNetwork": {
"type": "boolean"
},
"requestTimeoutMs": {
"type": "number"
},
"maxRedirects": {
"type": "number"
}
},
"default": {
"enabled": false,
"allowCustomProvider": false,
"providers": [],
"allowInsecureHttp": false,
"allowedHosts": [],
"blockPrivateNetwork": true,
"requestTimeoutMs": 10000,
"maxRedirects": 5
}
"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"
}
}
},
@@ -1155,42 +863,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -1200,9 +874,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
}
@@ -1224,42 +895,8 @@
},
"config": {
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"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": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
},
"forcePathStyle": {
"type": "boolean",
"description": "Whether to use path-style bucket addressing."
},
"requestTimeoutMs": {
"type": "number",
"description": "Request timeout in milliseconds."
},
"minPartSize": {
"type": "number",
"description": "Minimum multipart part size in bytes."
},
"presign": {
"type": "object",
"description": "Presigned URL behavior configuration.",
"properties": {
"expiresInSeconds": {
"type": "number",
"description": "Expiration time in seconds for presigned URLs."
},
"signContentTypeForPut": {
"type": "boolean",
"description": "Whether to sign Content-Type for presigned PUT."
}
}
},
"credentials": {
"type": "object",
"description": "The credentials for the s3 compatible storage provider.",
@@ -1269,9 +906,6 @@
},
"secretAccessKey": {
"type": "string"
},
"sessionToken": {
"type": "string"
}
}
},

View File

@@ -1,26 +0,0 @@
.git
.github/**/*.md
.gitignore
# Local dependency/build artifacts
/node_modules
/target
# Yarn v4 artifacts (not needed for image packaging)
/.yarn/cache
/.yarn/unplugged
/.yarn/install-state.gz
/.pnp.*
# Test artifacts
/test-results
/playwright-report
/coverage
/.coverage
# OS noise
.DS_Store
# Sourcemaps (keep server sourcemap for backend stacktraces)
**/*.map
!packages/backend/server/dist/main.js.map

View File

@@ -1,8 +1,6 @@
# Editor configuration, see http://editorconfig.org
root = true
[*.rs]
max_line_length = 120
[*]
charset = utf-8
indent_style = space

View File

@@ -2,6 +2,7 @@ name: Feature Request
description: Suggest a feature or improvement
title: '[Feature Request]: '
labels: ['feat', 'story']
assignees: ['hwangdev97']
body:
- type: markdown
attributes:

View File

@@ -25,32 +25,47 @@ const buildType = BUILD_TYPE || 'canary';
const isProduction = buildType === 'stable';
const isBeta = buildType === 'beta';
const isCanary = buildType === 'canary';
const isInternal = buildType === 'internal';
const isSpotEnabled = isBeta || isCanary;
const replicaConfig = {
stable: {
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
web: 2,
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
sync: Number(process.env.PRODUCTION_SYNC_REPLICA) || 2,
renderer: Number(process.env.PRODUCTION_RENDERER_REPLICA) || 2,
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
},
beta: {
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
web: 1,
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
sync: Number(process.env.BETA_SYNC_REPLICA) || 1,
renderer: Number(process.env.BETA_RENDERER_REPLICA) || 1,
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
},
canary: { front: 1, graphql: 1, doc: 1 },
canary: {
web: 1,
graphql: 1,
sync: 1,
renderer: 1,
doc: 1,
},
};
const cpuConfig = {
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
};
const memoryConfig = {
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
beta: {
web: '300m',
graphql: '1',
sync: '1',
doc: '1',
renderer: '300m',
},
canary: {
web: '300m',
graphql: '1',
sync: '1',
doc: '1',
renderer: '300m',
},
};
const createHelmCommand = ({ isDryRun }) => {
@@ -74,51 +89,32 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
];
const cloudSqlNodeSelector = isBeta
? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }`
: `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`;
const serviceAnnotations = [
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json web.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json sync.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
].concat(
isProduction || isBeta || isInternal
? [
`--set-json front.services.web.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json front.services.sync.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json web.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json sync.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
]
: []
);
const spotNodeSelector = `{ \\"cloud.google.com/gke-spot\\": \\"true\\" }`;
const spotScheduling = isSpotEnabled
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
`--set-json doc.nodeSelector="${spotNodeSelector}"`,
]
: [];
const cpu = cpuConfig[buildType];
const memory = memoryConfig[buildType];
let resources = [];
if (cpu) {
resources = resources.concat([
`--set front.resources.requests.cpu="${cpu.front}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]);
}
if (memory) {
resources = resources.concat([
`--set front.resources.requests.memory="${memory.front}"`,
`--set graphql.resources.requests.memory="${memory.graphql}"`,
`--set doc.resources.requests.memory="${memory.doc}"`,
]);
}
const resources = cpu
? [
`--set web.resources.requests.cpu="${cpu.web}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set sync.resources.requests.cpu="${cpu.sync}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]
: [];
const replica = replicaConfig[buildType] || replicaConfig.canary;
@@ -134,7 +130,6 @@ const createHelmCommand = ({ isDryRun }) => {
.split(',')
.map(host => host.trim())
.filter(host => host);
const primaryHost = hosts[0] || '0.0.0.0';
const deployCommand = [
`helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`,
@@ -149,17 +144,20 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.version="${APP_VERSION}"`,
...redisAndPostgres,
...indexerOptions,
`--set front.replicaCount=${replica.front}`,
`--set-string front.image.tag="${imageTag}"`,
`--set-string front.app.host="${primaryHost}"`,
`--set web.replicaCount=${replica.web}`,
`--set-string web.image.tag="${imageTag}"`,
`--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set-string graphql.app.host="${primaryHost}"`,
`--set graphql.app.host=${hosts[0]}`,
`--set sync.replicaCount=${replica.sync}`,
`--set-string sync.image.tag="${imageTag}"`,
`--set-string renderer.image.tag="${imageTag}"`,
`--set renderer.app.host=${hosts[0]}`,
`--set renderer.replicaCount=${replica.renderer}`,
`--set-string doc.image.tag="${imageTag}"`,
`--set-string doc.app.host="${primaryHost}"`,
`--set doc.app.host=${hosts[0]}`,
`--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations,
...spotScheduling,
...resources,
`--timeout 10m`,
flag,

13
.github/deployment/front/Dockerfile vendored Normal file
View File

@@ -0,0 +1,13 @@
FROM openresty/openresty:1.27.1.1-0-buster
WORKDIR /app
COPY ./packages/frontend/apps/web/dist ./dist
COPY ./packages/frontend/admin/dist ./admin
COPY ./packages/frontend/apps/mobile/dist ./mobile
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
RUN mkdir -p /var/log/nginx && \
rm /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]

View File

@@ -0,0 +1,42 @@
server {
listen 8080;
location /admin {
root /app/;
index index.html;
try_files $uri/index.html $uri/ $uri /admin/index.html;
}
set $app_root_path /app/dist/;
set $mobile_root /app/dist/;
set_by_lua $affine_env 'return os.getenv("AFFINE_ENV")';
if ($affine_env = "dev") {
set $mobile_root /app/mobile/;
}
# https://gist.github.com/mariusom/6683dc52b1cad1a1f372e908bdb209d0
if ($http_user_agent ~* "(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino") {
set $app_root_path $mobile_root;
}
if ($http_user_agent ~* "^(1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-)") {
set $app_root_path $mobile_root;
}
location ~ ^/(_plugin|assets|imgs|js|plugins|static)/ {
root $app_root_path;
try_files $uri $uri/ =404;
}
location / {
root $app_root_path;
index index.html;
try_files $uri $uri/ /index.html;
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate";
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}

15
.github/deployment/front/nginx.conf vendored Normal file
View File

@@ -0,0 +1,15 @@
worker_processes 4;
error_log /var/log/nginx/error.log warn;
pcre_jit on;
env AFFINE_ENV;
events {
worker_connections 1024;
}
http {
include mime.types;
log_format main '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -1,28 +1,11 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS assets
WORKDIR /app
FROM node:22-bookworm-slim
COPY ./packages/backend/server /app
COPY ./packages/frontend/apps/web/dist /app/static
COPY ./packages/frontend/admin/dist /app/static/admin
COPY ./packages/frontend/apps/mobile/dist /app/static/mobile
# Keep server sourcemap for stacktraces, but don't ship frontend/node_modules sourcemaps.
ARG TARGETARCH
ARG TARGETVARIANT
# Needed for Prisma engine resolution (and potential engine download during cleanup).
RUN apt-get update && \
apt-get install -y --no-install-recommends openssl ca-certificates && \
rm -rf /var/lib/apt/lists/*
RUN AFFINE_DOCKER_CLEAN=1 TARGETARCH="${TARGETARCH}" TARGETVARIANT="${TARGETVARIANT}" node ./scripts/docker-clean.mjs
FROM node:22-bookworm-slim
WORKDIR /app
COPY --from=assets /app /app
RUN apt-get update && \
apt-get install -y --no-install-recommends openssl libjemalloc2 && \
rm -rf /var/lib/apt/lists/*
@@ -30,6 +13,4 @@ RUN apt-get update && \
# Enable jemalloc by preloading the library
ENV LD_PRELOAD=libjemalloc.so.2
EXPOSE 3010
CMD ["node", "./dist/main.js"]

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.26.1"
appVersion: "0.25.7"

View File

@@ -3,7 +3,7 @@ name: doc
description: AFFiNE doc server
type: application
version: 0.0.0
appVersion: "0.26.1"
appVersion: "0.25.7"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -30,12 +30,9 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
memory: 4Gi
probe:
initialDelaySeconds: 20

View File

@@ -1,120 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "front.fullname" . }}
labels:
{{- include "front.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "front.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "front.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "front.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "{{ .Values.nodeOptions }}"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "front"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.app.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.app.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,19 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.services.renderer.name }}
labels:
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.renderer.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.services.renderer.type }}
ports:
- port: {{ .Values.services.renderer.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -1,19 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.services.sync.name }}
labels:
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.sync.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.services.sync.type }}
ports:
- port: {{ .Values.services.sync.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -1,19 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.services.web.name }}
labels:
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.web.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.services.web.type }}
ports:
- port: {{ .Values.services.web.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -1,63 +0,0 @@
replicaCount: 1
image:
repository: ghcr.io/toeverything/affine
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets: []
nameOverride: ''
fullnameOverride: ''
# map to NODE_ENV environment variable
env: 'production'
nodeOptions: '--max-old-space-size=3072'
app:
# AFFINE_SERVER_PORT
port: 3010
# AFFINE_SERVER_SUB_PATH
path: ''
# AFFINE_SERVER_HOST
host: '0.0.0.0'
https: true
serviceAccount:
create: true
annotations: {}
name: 'affine-front'
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 2Gi
requests:
cpu: '1'
memory: 2Gi
probe:
initialDelaySeconds: 20
services:
sync:
name: affine-sync
type: ClusterIP
port: 3010
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
renderer:
name: affine-renderer
type: ClusterIP
port: 3000
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
web:
name: affine-web
type: ClusterIP
port: 8080
annotations: {}
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.26.1"
appVersion: "0.25.7"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -27,11 +27,8 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
cpu: '2'
memory: 2Gi
probe:

View File

@@ -0,0 +1,11 @@
apiVersion: v2
name: renderer
description: AFFiNE renderer server
type: application
version: 0.0.0
appVersion: "0.25.7"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0
repository: "file://../gcloud-sql-proxy"
condition: .global.database.gcloud.enabled

View File

@@ -1,15 +1,15 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.services.sync.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ .Values.services.sync.name }})
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "renderer.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.services.sync.type }}
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ .Values.services.sync.name }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ .Values.services.sync.name }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.services.sync.port }}
{{- else if contains "ClusterIP" .Values.services.sync.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "front.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "renderer.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "renderer.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "renderer.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT

View File

@@ -0,0 +1,63 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "renderer.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "renderer.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "renderer.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "renderer.labels" -}}
helm.sh/chart: {{ include "renderer.chart" . }}
{{ include "renderer.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "renderer.selectorLabels" -}}
app.kubernetes.io/name: {{ include "renderer.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "renderer.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "renderer.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,118 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "renderer.fullname" . }}
labels:
{{- include "renderer.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "renderer.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "renderer.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "renderer.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "--max-old-space-size=2048"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "renderer"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "graphql.fullname" . }}
labels:
{{- include "graphql.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "graphql.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "graphql.serviceAccountName" . }}
labels:
{{- include "graphql.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "renderer.fullname" . }}-test-connection"
labels:
{{- include "renderer.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "renderer.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,38 @@
replicaCount: 1
image:
repository: ghcr.io/toeverything/affine
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets: []
nameOverride: ''
fullnameOverride: ''
# map to NODE_ENV environment variable
env: 'production'
app:
# AFFINE_SERVER_SUB_PATH
path: ''
# AFFINE_SERVER_HOST
host: '0.0.0.0'
https: true
serviceAccount:
create: true
annotations: {}
name: 'affine-renderer'
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
resources:
requests:
cpu: '1'
memory: 2Gi
probe:
initialDelaySeconds: 20
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -1,9 +1,9 @@
apiVersion: v2
name: front
description: AFFiNE front server
name: sync
description: AFFiNE Sync Server
type: application
version: 0.0.0
appVersion: "0.26.1"
appVersion: "0.25.7"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -0,0 +1,16 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "sync.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "sync.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "sync.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "sync.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -1,7 +1,7 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "front.name" -}}
{{- define "sync.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
@@ -10,7 +10,7 @@ Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "front.fullname" -}}
{{- define "sync.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
@@ -26,16 +26,16 @@ If release name contains chart name it will be used as a full name.
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "front.chart" -}}
{{- define "sync.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "front.labels" -}}
helm.sh/chart: {{ include "front.chart" . }}
{{ include "front.selectorLabels" . }}
{{- define "sync.labels" -}}
helm.sh/chart: {{ include "sync.chart" . }}
{{ include "sync.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
@@ -46,17 +46,17 @@ monitoring: enabled
{{/*
Selector labels
*/}}
{{- define "front.selectorLabels" -}}
app.kubernetes.io/name: {{ include "front.name" . }}
{{- define "sync.selectorLabels" -}}
app.kubernetes.io/name: {{ include "sync.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "front.serviceAccountName" -}}
{{- define "sync.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "front.fullname" .) .Values.serviceAccount.name }}
{{- default (include "sync.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}

View File

@@ -0,0 +1,112 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "sync.fullname" . }}
labels:
{{- include "sync.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "sync.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "sync.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "sync.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "sync"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "sync.fullname" . }}
labels:
{{- include "sync.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "sync.selectorLabels" . | nindent 4 }}

View File

@@ -2,9 +2,9 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "front.serviceAccountName" . }}
name: {{ include "sync.serviceAccountName" . }}
labels:
{{- include "front.labels" . | nindent 4 }}
{{- include "sync.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}

View File

@@ -1,9 +1,9 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "front.fullname" . }}-test-connection"
name: "{{ include "sync.fullname" . }}-test-connection"
labels:
{{- include "front.labels" . | nindent 4 }}
{{- include "sync.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
@@ -11,5 +11,5 @@ spec:
- name: wget
image: busybox
command: ['wget']
args: ['{{ .Values.services.sync.name }}:{{ .Values.services.sync.port }}']
args: ['{{ include "sync.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,38 @@
replicaCount: 1
image:
repository: ghcr.io/toeverything/affine
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets: []
nameOverride: ''
fullnameOverride: ''
# map to NODE_ENV environment variable
env: 'production'
app:
# AFFINE_SERVER_HOST
host: '0.0.0.0'
serviceAccount:
create: true
annotations: {}
name: 'affine-sync'
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '2'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
probe:
initialDelaySeconds: 20
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: web
description: A Helm chart for Kubernetes
type: application
version: 0.0.0
appVersion: "0.7.0-canary.18"

View File

@@ -0,0 +1,16 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "web.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "web.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "web.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "web.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,63 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "web.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "web.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "web.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "web.labels" -}}
helm.sh/chart: {{ include "web.chart" . }}
{{ include "web.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "web.selectorLabels" -}}
app.kubernetes.io/name: {{ include "web.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "web.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "web.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "web.fullname" . }}
labels:
{{- include "web.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "web.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "web.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "web.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "web.fullname" . }}
labels:
{{- include "web.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "web.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "web.serviceAccountName" . }}
labels:
{{- include "web.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "web.fullname" . }}-test-connection"
labels:
{{- include "web.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "web.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,37 @@
replicaCount: 1
image:
repository: ghcr.io/toeverything/affine-front
pullPolicy: IfNotPresent
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: "affine-web"
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '500m'
memory: 2Gi
requests:
cpu: '500m'
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}
probe:
initialDelaySeconds: 1

View File

@@ -44,9 +44,9 @@ spec:
pathType: Prefix
backend:
service:
name: {{ $.Values.front.services.sync.name }}
name: affine-sync
port:
number: {{ $.Values.front.services.sync.port }}
number: {{ $.Values.sync.service.port }}
- path: /graphql
pathType: Prefix
backend:
@@ -65,15 +65,15 @@ spec:
pathType: Prefix
backend:
service:
name: {{ $.Values.front.services.renderer.name }}
name: affine-renderer
port:
number: {{ $.Values.front.services.renderer.port }}
number: {{ $.Values.renderer.service.port }}
- path: /
pathType: Prefix
backend:
service:
name: {{ $.Values.front.services.web.name }}
name: affine-web
port:
number: {{ $.Values.front.services.web.port }}
number: {{ $.Values.web.service.port }}
{{- end }}
{{- end }}

View File

@@ -47,27 +47,27 @@ graphql:
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
sync:
service:
type: ClusterIP
port: 3010
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
renderer:
service:
type: ClusterIP
port: 3000
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
doc:
service:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
front:
services:
sync:
name: affine-sync
type: ClusterIP
port: 3010
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
renderer:
name: affine-renderer
type: ClusterIP
port: 3000
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
web:
name: affine-web
type: ClusterIP
port: 8080
web:
service:
type: ClusterIP
port: 8080

View File

@@ -45,6 +45,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
@@ -77,6 +78,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload admin artifact
uses: actions/upload-artifact@v4
with:
@@ -109,6 +111,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload mobile artifact
uses: actions/upload-artifact@v4
with:
@@ -263,7 +266,18 @@ jobs:
with:
app-version: ${{ inputs.app-version }}
- name: Build backend Dockerfile
- name: Build front Dockerfile
uses: docker/build-push-action@v6
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64
provenance: true
file: .github/deployment/front/Dockerfile
tags: ghcr.io/toeverything/affine-front:${{inputs.build-type}}-${{ inputs.git-short-hash }}
- name: Build graphql Dockerfile
uses: docker/build-push-action@v6
with:
context: .

View File

@@ -182,7 +182,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -213,7 +213,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1]
shard: [1, 2]
browser: ['chromium', 'firefox', 'webkit']
steps:
- uses: actions/checkout@v4
@@ -251,7 +251,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -282,7 +282,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2]
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -313,7 +313,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -507,8 +507,8 @@ jobs:
strategy:
fail-fast: false
matrix:
node_index: [0, 1, 2, 3]
total_nodes: [4]
node_index: [0, 1, 2, 3, 4, 5, 6, 7]
total_nodes: [8]
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -798,6 +798,49 @@ jobs:
name: fuzz-artifact
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
y-octo-binding-test:
name: y-octo binding test on ${{ matrix.settings.target }}
runs-on: ${{ matrix.settings.os }}
strategy:
fail-fast: false
matrix:
settings:
- { target: 'x86_64-unknown-linux-gnu', os: 'ubuntu-latest' }
- { target: 'aarch64-unknown-linux-gnu', os: 'ubuntu-24.04-arm' }
- { target: 'x86_64-apple-darwin', os: 'macos-15-intel' }
- { target: 'aarch64-apple-darwin', os: 'macos-latest' }
- { target: 'x86_64-pc-windows-msvc', os: 'windows-latest' }
- { target: 'aarch64-pc-windows-msvc', os: 'windows-11-arm' }
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine-tools/cli @affine/monorepo @y-octo/node
electron-install: false
- name: Install rustup (Windows 11 ARM)
if: matrix.settings.os == 'windows-11-arm'
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://static.rust-lang.org/rustup/dist/aarch64-pc-windows-msvc/rustup-init.exe" -OutFile rustup-init.exe
.\rustup-init.exe --default-toolchain none -y
"$env:USERPROFILE\.cargo\bin" | Out-File -Append -Encoding ascii $env:GITHUB_PATH
"CARGO_HOME=$env:USERPROFILE\.cargo" | Out-File -Append -Encoding ascii $env:GITHUB_ENV
- name: Install Rust (Windows 11 ARM)
if: matrix.settings.os == 'windows-11-arm'
shell: pwsh
run: |
rustup install stable
rustup target add ${{ matrix.settings.target }}
cargo --version
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.settings.target }}
package: '@y-octo/node'
- name: Run tests
run: yarn affine @y-octo/node test
rust-test:
name: Run native tests
runs-on: ubuntu-latest
@@ -924,8 +967,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5]
shardTotal: [5]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shardTotal: [10]
needs:
- build-server-native
services:
@@ -1016,12 +1059,36 @@ jobs:
fail-fast: false
matrix:
tests:
- name: 'Cloud E2E Test 1/2'
- name: 'Cloud E2E Test 1/10'
shard: 1
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/2
- name: 'Cloud E2E Test 2/2'
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/10
- name: 'Cloud E2E Test 2/10'
shard: 2
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/2
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/10
- name: 'Cloud E2E Test 3/10'
shard: 3
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=3/10
- name: 'Cloud E2E Test 4/10'
shard: 4
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=4/10
- name: 'Cloud E2E Test 5/10'
shard: 5
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=5/10
- name: 'Cloud E2E Test 6/10'
shard: 6
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=6/10
- name: 'Cloud E2E Test 7/10'
shard: 7
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=7/10
- name: 'Cloud E2E Test 8/10'
shard: 8
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=8/10
- name: 'Cloud E2E Test 9/10'
shard: 9
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=9/10
- name: 'Cloud E2E Test 10/10'
shard: 10
script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=10/10
- name: 'Cloud Desktop E2E Test'
shard: desktop
script: |
@@ -1320,6 +1387,7 @@ jobs:
- miri
- loom
- fuzzing
- y-octo-binding-test
- server-test
- server-e2e-test
- rust-test

View File

@@ -68,6 +68,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE: ${{ inputs.app_version }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
steps:
- uses: actions/checkout@v4

View File

@@ -66,6 +66,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE: ${{ inputs.app-version }}
RELEASE_VERSION: ${{ inputs.app-version }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload web artifact
uses: actions/upload-artifact@v4

View File

@@ -39,6 +39,7 @@ jobs:
run: yarn affine @affine/ios build
env:
PUBLIC_PATH: '/'
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -67,6 +68,7 @@ jobs:
run: yarn affine @affine/android build
env:
PUBLIC_PATH: '/'
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -108,7 +110,7 @@ jobs:
enableScripts: false
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 26.2
xcode-version: 16.4
- name: Install Swiftformat
run: brew install swiftformat
- name: Cap sync

72
.github/workflows/sync-i18n.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Sync I18n with Crowdin
on:
push:
branches:
- canary
paths:
- 'packages/frontend/i18n/**'
workflow_dispatch:
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crowdin action
id: crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: true
auto_approve_imported: true
import_eq_suggestions: true
export_only_approved: true
skip_untranslated_strings: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: 'chore(i18n): sync translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'canary'
config: packages/frontend/i18n/crowdin.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
i18n-codegen:
needs: synchronize-with-crowdin
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: l10n_crowdin_translations
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
full-cache: true
- name: Run i18n codegen
run: yarn affine @affine/i18n build
- name: Commit changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "chore(i18n): i18n codegen"
git push origin l10n_crowdin_translations

3
.gitignore vendored
View File

@@ -47,7 +47,8 @@ testem.log
.pnpm-debug.log
/typings
tsconfig.tsbuildinfo
.context
rfc*.md
todo.md
# System Files
.DS_Store

2
.nvmrc
View File

@@ -1 +1 @@
22.22.0
22.21.1

View File

@@ -1,8 +1,4 @@
exclude = [
"node_modules/**/*.toml",
"target/**/*.toml",
"packages/frontend/apps/ios/App/Packages/AffineGraphQL/**/*.toml",
]
exclude = ["node_modules/**/*.toml", "target/**/*.toml"]
# https://taplo.tamasfe.dev/configuration/formatter-options.html
[formatting]

View File

@@ -1,5 +1,5 @@
{
"prisma.pinToPrisma6": true,
"eslint.packageManager": "yarn",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
@@ -14,13 +14,11 @@
"testid",
"schemars"
],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, oxlint.json, nyc.config.*",
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, crowdin*, cypress.*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.config.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json",
"Cargo.toml": "Cargo.lock",
"README.md": "LICENSE, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md"
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
@@ -34,6 +32,5 @@
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
"rust-analyzer.check.extraEnv": {
"DATABASE_URL": "sqlite:affine.db"
},
"typescript.tsdk": "node_modules/typescript/lib"
}
}

1597
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ members = [
"./packages/backend/native",
"./packages/common/native",
"./packages/common/y-octo/core",
"./packages/common/y-octo/node",
"./packages/common/y-octo/utils",
"./packages/frontend/mobile-native",
"./packages/frontend/native",
@@ -46,7 +47,7 @@ resolver = "3"
libc = "0.2"
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
memory-indexer = "0.3.0"
memory-indexer = "0.2.1"
mimalloc = "0.1"
mp4parse = "0.17"
nanoid = "0.4"
@@ -63,6 +64,7 @@ resolver = "3"
notify = { version = "8", features = ["serde"] }
objc2 = "0.6"
objc2-foundation = "0.3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
parking_lot = "0.12"
@@ -71,7 +73,6 @@ resolver = "3"
phf = { version = "0.11", features = ["macros"] }
proptest = "1.3"
proptest-derive = "0.5"
pulldown-cmark = "0.13"
rand = "0.9"
rand_chacha = "0.9"
rand_distr = "0.5"

View File

@@ -21,6 +21,23 @@
<br/>
<br/>
<div align="left" valign="middle">
<a href="https://runblaze.dev">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.runblaze.dev/logo_dark.png">
<img align="right" src="https://www.runblaze.dev/logo_light.png" height="102px"/>
</picture>
</a>
<br style="display: none;"/>
_Special thanks to [Blaze](https://runblaze.dev) for their support of this project. They provide high-performance Apple Silicon macOS and Linux (AMD64 & ARM64) runners for GitHub Actions, greatly reducing our automated build times._
</div>
<br/>
<br/>
<div align="center">
<a href="https://affine.pro">Home Page</a> |
<a href="https://affine.pro/redirect/discord">Discord</a> |
@@ -90,10 +107,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
## Contributing
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Vist the AFFiNE Community](https://community.affine.pro) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what youre made of.
@@ -152,10 +169,8 @@ Welcome to the AFFiNE blog section! Here, youll find the latest insights, tip
We would also like to give thanks to open-source projects that make AFFiNE possible:
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
- [y-octo](https://github.com/y-crdt/y-octo) - 🐙 y-octo is a native, high-performance, thread-safe YJS CRDT implementation, serving as the core engine enabling the AFFiNE Client/Server to achieve "local-first" functionality.
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync on web.
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync.
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
@@ -206,6 +221,12 @@ See [BUILDING.md] for instructions on how to build AFFiNE from source code.
We welcome contributions from everyone.
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
## Thanks
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
## License
### Editions

View File

@@ -6,8 +6,8 @@ We recommend users to always use the latest major version. Security updates will
| Version | Supported |
| --------------- | ------------------ |
| 0.26.x (stable) | :white_check_mark: |
| < 0.26.x | :x: |
| 0.25.x (stable) | :white_check_mark: |
| < 0.25.x | :x: |
## Reporting a Vulnerability

View File

@@ -296,7 +296,7 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1",
"version": "0.25.7",
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"msw": "^2.12.4",

View File

@@ -1,95 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -41,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"emoji-mart": "^5.6.0",
"lit": "^3.2.0",
@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -28,7 +28,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -28,7 +28,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -135,10 +135,14 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});

View File

@@ -1,56 +0,0 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}

View File

@@ -8,7 +8,10 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -23,7 +26,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -269,13 +271,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -301,10 +300,22 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {

View File

@@ -4,7 +4,10 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -17,7 +20,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -93,9 +95,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (!inlineRange) return;
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,15 +121,14 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
inlineEditor?.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -140,7 +139,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor.setInlineRange({
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -152,10 +151,22 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -229,8 +240,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste,
true
this._onPaste
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {

View File

@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -39,5 +39,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
@@ -43,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -26,10 +26,10 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -26,10 +26,10 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -10,6 +10,7 @@ const GENERIC_DEFAULT_HEIGHT_IN_NOTE = 400;
* These are based on the centralized cloud constants and known AFFiNE domains
*/
const AFFINE_DOMAINS = [
'affine.pro', // Main AFFiNE domain
'app.affine.pro', // Stable cloud domain
'insider.affine.pro', // Beta/internal cloud domain
'affine.fail', // Canary cloud domain

View File

@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/katex": "^0.16.7",
"@types/mdast": "^4.0.4",
"katex": "^0.16.27",
@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -27,12 +27,12 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -44,12 +44,12 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
@@ -67,5 +67,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -25,11 +25,11 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.6",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -20,12 +20,12 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.6",
"pdf-lib": "^1.17.1",
"rxjs": "^7.8.2",
@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -21,7 +21,7 @@
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
@@ -31,7 +31,7 @@
"katex": "^0.16.27",
"lit": "^3.2.0",
"lit-html": "^3.2.1",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"remark-math": "^6.0.0",
"rxjs": "^7.8.2",
"shiki": "^3.19.0",
@@ -82,5 +82,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -21,12 +21,12 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"clsx": "^2.1.1",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.1"
"version": "0.25.7"
}

View File

@@ -1,517 +0,0 @@
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
import { textPropertyModelConfig } from '../property-presets/text/define.js';
import {
canGroupable,
ensureKanbanGroupColumn,
pickKanbanGroupColumn,
resolveKanbanGroupBy,
} from '../view-presets/kanban/group-by-utils.js';
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
type Column = {
id: string;
type: string;
data?: Record<string, unknown>;
};
type TestPropertyMeta = {
type: string;
config: {
kanbanGroup?: {
enabled: boolean;
mutable?: boolean;
};
propertyData: {
default: () => Record<string, unknown>;
};
jsonValue: {
type: (options: {
data: Record<string, unknown>;
dataSource: DataSource;
}) => unknown;
};
};
};
type MockDataSource = {
properties$: ReturnType<typeof signal<string[]>>;
provider: {
getAll: () => Map<unknown, unknown>;
};
serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown;
propertyTypeGet: (propertyId: string) => string | undefined;
propertyMetaGet: (type: string) => TestPropertyMeta | undefined;
propertyDataGet: (propertyId: string) => Record<string, unknown>;
propertyDataTypeGet: (propertyId: string) => unknown;
propertyAdd: (
_position: unknown,
ops?: {
type?: string;
}
) => string;
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => void;
};
const asDataSource = (dataSource: object): DataSource =>
dataSource as DataSource;
const toTestMeta = <TData extends Record<string, unknown>>(
type: string,
config: {
kanbanGroup?: {
enabled: boolean;
mutable?: boolean;
};
propertyData: {
default: () => TData;
};
jsonValue: {
type: (options: { data: TData; dataSource: DataSource }) => unknown;
};
}
): TestPropertyMeta => ({
type,
config: {
kanbanGroup: config.kanbanGroup,
propertyData: {
default: () => config.propertyData.default(),
},
jsonValue: {
type: ({ data, dataSource }) =>
config.jsonValue.type({
data: data as TData,
dataSource,
}),
},
},
});
const immutableBooleanMeta = toTestMeta('immutable-boolean', {
...checkboxPropertyModelConfig.config,
kanbanGroup: {
enabled: true,
mutable: false,
},
});
const createMockDataSource = (columns: Column[]): MockDataSource => {
const properties$ = signal(columns.map(column => column.id));
const typeById = new Map(columns.map(column => [column.id, column.type]));
const dataById = new Map(
columns.map(column => [column.id, column.data ?? {}])
);
const services = new Map<unknown, unknown>();
const metaEntries: Array<[string, TestPropertyMeta]> = [
[
checkboxPropertyModelConfig.type,
toTestMeta(
checkboxPropertyModelConfig.type,
checkboxPropertyModelConfig.config
),
],
[
selectPropertyModelConfig.type,
toTestMeta(
selectPropertyModelConfig.type,
selectPropertyModelConfig.config
),
],
[
multiSelectPropertyModelConfig.type,
toTestMeta(
multiSelectPropertyModelConfig.type,
multiSelectPropertyModelConfig.config
),
],
[
textPropertyModelConfig.type,
toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config),
],
[immutableBooleanMeta.type, immutableBooleanMeta],
];
const metaByType = new Map(metaEntries);
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value != null
? (value as Record<string, unknown>)
: {};
let autoColumnId = 0;
const dataSource = {
properties$,
provider: {
getAll: () => new Map<unknown, unknown>(),
},
serviceGetOrCreate: (key: unknown, create: () => unknown) => {
if (!services.has(key)) {
services.set(key, create());
}
return services.get(key);
},
propertyTypeGet: (propertyId: string) => typeById.get(propertyId),
propertyMetaGet: (type: string) => metaByType.get(type),
propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)),
propertyDataTypeGet: (propertyId: string) => {
const type = typeById.get(propertyId);
if (!type) {
return;
}
const meta = metaByType.get(type);
if (!meta) {
return;
}
return meta.config.jsonValue.type({
data: asRecord(dataById.get(propertyId)),
dataSource: asDataSource(dataSource),
});
},
propertyAdd: (
_position: unknown,
ops?: {
type?: string;
}
) => {
const type = ops?.type ?? selectPropertyModelConfig.type;
const id = `auto-${++autoColumnId}`;
const meta = metaByType.get(type);
const data = meta?.config.propertyData.default() ?? {};
typeById.set(id, type);
dataById.set(id, data);
properties$.value = [...properties$.value, id];
return id;
},
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => {
dataById.set(propertyId, data);
},
};
return dataSource;
};
const createDragController = () => {
type DragLogic = ConstructorParameters<typeof KanbanDragController>[0];
return new KanbanDragController({} as DragLogic);
};
describe('kanban', () => {
describe('group-by define', () => {
it('boolean group should not include ungroup bucket', () => {
const booleanGroup = groupByMatchers.find(
group => group.name === 'boolean'
);
expect(booleanGroup).toBeDefined();
const keys = booleanGroup!
.defaultKeys(t.boolean.instance())
.map(group => group.key);
expect(keys).toEqual(['true', 'false']);
});
it('boolean group should fallback invalid values to false bucket', () => {
const booleanGroup = groupByMatchers.find(
group => group.name === 'boolean'
);
expect(booleanGroup).toBeDefined();
const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance());
expect(groups).toEqual([{ key: 'false', value: false }]);
});
});
describe('columns materialization', () => {
it('appends missing properties while preserving existing order and state', () => {
const columns = [{ id: 'status', hide: true }, { id: 'title' }];
const next = materializeKanbanColumns(columns, [
'title',
'status',
'date',
]);
expect(next).toEqual([
{ id: 'status', hide: true },
{ id: 'title' },
{ id: 'date' },
]);
});
it('drops stale columns that no longer exist in data source', () => {
const columns = [{ id: 'title' }, { id: 'removed', hide: true }];
const next = materializeKanbanColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title' }]);
});
it('returns original reference when columns are already materialized', () => {
const columns = [{ id: 'title' }, { id: 'status', hide: true }];
const next = materializeKanbanColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
});
describe('drag indicator', () => {
it('shows drop preview when insert position exists', () => {
const controller = createDragController();
const position = {
group: {} as KanbanGroup,
position: 'end' as const,
};
controller.getInsertPosition = vi.fn().mockReturnValue(position);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
const result = controller.showIndicator({} as MouseEvent, undefined);
expect(result).toBe(position);
expect(displaySpy).toHaveBeenCalledWith(
position.group,
undefined,
undefined
);
expect(removeSpy).not.toHaveBeenCalled();
});
it('removes drop preview when insert position does not exist', () => {
const controller = createDragController();
controller.getInsertPosition = vi.fn().mockReturnValue(undefined);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
const result = controller.showIndicator({} as MouseEvent, undefined);
expect(result).toBeUndefined();
expect(displaySpy).not.toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalledOnce();
});
it('forwards hovered card to drop preview for precise insertion cursor', () => {
const controller = createDragController();
const hoveredCard = document.createElement(
'affine-data-view-kanban-card'
) as KanbanCard;
const positionCard = document.createElement(
'affine-data-view-kanban-card'
) as KanbanCard;
const position = {
group: {} as KanbanGroup,
card: positionCard,
position: { before: true, id: 'card-id' } as const,
};
controller.getInsertPosition = vi.fn().mockReturnValue(position);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
controller.showIndicator({} as MouseEvent, hoveredCard);
expect(displaySpy).toHaveBeenCalledWith(
position.group,
hoveredCard,
position.card
);
});
});
describe('group-by utils', () => {
it('allows only kanban-enabled property types to group', () => {
const dataSource = createMockDataSource([
{ id: 'text', type: textPropertyModelConfig.type },
{ id: 'select', type: selectPropertyModelConfig.type },
{ id: 'multi-select', type: multiSelectPropertyModelConfig.type },
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
]);
expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false);
expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true);
expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true);
expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true);
});
it('prefers mutable group column over immutable ones', () => {
const dataSource = createMockDataSource([
{
id: 'immutable-bool',
type: 'immutable-boolean',
},
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox');
});
it('creates default status select column when no groupable column exists', () => {
const dataSource = createMockDataSource([
{
id: 'text',
type: textPropertyModelConfig.type,
},
]);
const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource));
expect(statusColumnId).toBeTruthy();
expect(dataSource.propertyTypeGet(statusColumnId!)).toBe(
selectPropertyModelConfig.type
);
const options =
(
dataSource.propertyDataGet(statusColumnId!) as {
options?: { value: string }[];
}
).options ?? [];
expect(options.map(option => option.value)).toEqual([
'Todo',
'In Progress',
'Done',
]);
});
it('defaults hideEmpty to true for non-option groups', () => {
const dataSource = createMockDataSource([
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource));
expect(next?.columnId).toBe('checkbox');
expect(next?.hideEmpty).toBe(true);
expect(next?.name).toBe('boolean');
});
it('defaults hideEmpty to false for select grouping', () => {
const dataSource = createMockDataSource([
{
id: 'select',
type: selectPropertyModelConfig.type,
},
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource));
expect(next?.columnId).toBe('select');
expect(next?.hideEmpty).toBe(false);
expect(next?.name).toBe('select');
});
it('preserves sort and explicit hideEmpty when resolving groupBy', () => {
const dataSource = createMockDataSource([
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
const current: GroupBy = {
type: 'groupBy',
columnId: 'checkbox',
name: 'boolean',
sort: { desc: true },
hideEmpty: true,
};
const next = resolveKanbanGroupBy(asDataSource(dataSource), current);
expect(next?.columnId).toBe('checkbox');
expect(next?.sort).toEqual({ desc: true });
expect(next?.hideEmpty).toBe(true);
});
it('replaces current non-groupable column with a valid kanban column', () => {
const dataSource = createMockDataSource([
{ id: 'text', type: textPropertyModelConfig.type },
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource), {
type: 'groupBy',
columnId: 'text',
name: 'text',
});
expect(next?.columnId).toBe('checkbox');
expect(next?.name).toBe('boolean');
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});

View File

@@ -1,101 +0,0 @@
import { describe, expect, test } from 'vitest';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -22,6 +22,7 @@ import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
@@ -74,38 +75,12 @@ export class DataViewRootUILogic {
return new (logic(view))(this, view);
}
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.value.map(logic => [logic.view.id, logic])
this.views$.list.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();

View File

@@ -1,6 +1,7 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import type { RecordDetail } from './detail.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
@@ -8,39 +9,16 @@ type DetailViewSelection = {
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
if (selection && isSameDetailSelection(this._selection, selection)) {
this._selection = selection;
if (selection) {
this.focus(selection);
}
};
@@ -71,7 +49,7 @@ export class DetailSelection {
}
}
constructor(private readonly viewEle: DetailSelectionHost) {}
constructor(private readonly viewEle: RecordDetail) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
@@ -133,10 +111,8 @@ export class DetailSelection {
}
focusFirstCell() {
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
@@ -168,12 +144,11 @@ export class DetailSelection {
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
return this.viewEle
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -247,13 +247,12 @@ export const groupByMatchers: GroupByConfig[] = [
matchType: t.boolean.instance(),
groupName: (_t, v) => `${v?.toString() ?? ''}`,
defaultKeys: _t => [
ungroups,
{ key: 'true', value: true },
{ key: 'false', value: false },
],
valuesGroup: (v, _t) =>
typeof v !== 'boolean'
? [{ key: 'false', value: false }]
: [{ key: v.toString(), value: v }],
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
addToGroup: (v: boolean | null, _old: boolean | null) => v,
view: createUniComponentFromWebComponent(BooleanGroupView),
}),

View File

@@ -17,7 +17,6 @@ import { css, html, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { canGroupable } from '../../view-presets/kanban/group-by-utils.js';
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
import { dataViewCssVariable } from '../common/css-variable.js';
@@ -279,9 +278,6 @@ export const selectGroupByProperty = (
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;

View File

@@ -16,10 +16,6 @@ export type GetJsonValueFromConfig<T> =
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
name: string;
hide?: boolean;
kanbanGroup?: {
enabled: boolean;
mutable?: boolean;
};
propertyData: {
schema: ZodType<Data>;
default: () => Data;

View File

@@ -48,41 +48,32 @@ const compareList = <T>(
return 0;
};
const compareString = (a: unknown, b: unknown): CompareType => {
const strA = String(a ?? '');
const strB = String(b ?? '');
if (strA === '' && strB !== '') {
return Compare.GT; // Empty strings come last
if (typeof a != 'string' || a === '') {
return Compare.GT;
}
if (strA !== '' && strB === '') {
return Compare.LT; // Empty strings come last
if (typeof b != 'string' || b === '') {
return Compare.LT;
}
if (strA === '' && strB === '') {
return 0; // Both empty, equal
}
const listA = strA.split('.');
const listB = strB.split('.');
const listA = a.split('.');
const listB = b.split('.');
return compareList(listA, listB, (a, b) => {
const lowA = String(a).toLowerCase(); // Ensure 'a' and 'b' from split are strings too
const lowB = String(b).toLowerCase();
const lowA = a.toLowerCase();
const lowB = b.toLowerCase();
const numberA = Number.parseInt(lowA);
const numberB = Number.parseInt(lowB);
const aIsNaN = Number.isNaN(numberA);
const bIsNaN = Number.isNaN(numberB);
if (aIsNaN && !bIsNaN) {
return 1; // Non-numeric part comes after numeric part
return 1;
}
if (!aIsNaN && bIsNaN) {
return -1; // Numeric part comes before non-numeric part
return -1;
}
if (!aIsNaN && !bIsNaN && numberA !== numberB) {
return numberA - numberB; // Numeric comparison for numeric parts
return numberA - numberB;
}
return lowA.localeCompare(lowB); // Lexicographical comparison for string parts
return lowA.localeCompare(lowB);
});
};
const compareNumber = (a: unknown, b: unknown) => {

View File

@@ -12,5 +12,6 @@ export type PropertyDataUpdater<
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_number_formatting: boolean;
enable_table_virtual_scroll: boolean;
}

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