diff --git a/.env.example b/.env.example index 9f03b93e5a..815f612560 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,8 @@ DOCREADER_TRANSPORT=grpc # ========== B1. 数据库 ⚠️ 必填 ========== # 主数据库类型:postgres / mysql / sqlite。 +# 使用 MySQL 时:DB_DRIVER=mysql、DB_HOST=mysql、DB_PORT=3306,并搭配非 postgres 的 RETRIEVE_DRIVER +# (如 qdrant / milvus / weaviate / elasticsearch_v8 / opensearch / doris / tencent_vectordb)。 DB_DRIVER=postgres # 数据库主机地址。 DB_HOST=postgres @@ -111,6 +113,8 @@ DB_USER=postgres DB_PASSWORD=postgres123!@# # 数据库名称。 DB_NAME=WeKnora +# MySQL root 密码(仅 docker-compose 的 mysql 服务使用;留空默认复用 DB_PASSWORD)。 +# MYSQL_ROOT_PASSWORD= # SQLite 驱动时使用(DB_DRIVER=sqlite),postgres/mysql 忽略。 # DB_PATH=./data/weknora.db diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d6e264dd83..e0cc3db13f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,6 +23,34 @@ services: restart: unless-stopped stop_grace_period: 1m + mysql: + image: mysql:8.4 + container_name: WeKnora-mysql-dev + ports: + - "${DB_PORT:-3306}:3306" + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-weknora-root-password} + - MYSQL_DATABASE=${DB_NAME} + - MYSQL_USER=${DB_USER} + - MYSQL_PASSWORD=${DB_PASSWORD} + - TZ=${TZ:-Asia/Shanghai} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql-data-dev:/var/lib/mysql + networks: + - WeKnora-network-dev + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"] + interval: 10s + timeout: 10s + retries: 5 + start_period: 30s + restart: unless-stopped + profiles: + - mysql + redis: image: redis:7.0-alpine container_name: WeKnora-redis-dev @@ -574,6 +602,7 @@ networks: volumes: postgres-data-dev: + mysql-data-dev: redis_data_dev: minio_data_dev: neo4j-data-dev: diff --git a/docker-compose.yml b/docker-compose.yml index 6bdda41eb2..2d221e8d39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -499,7 +499,7 @@ services: - WeKnora-network restart: unless-stopped - # 修改的PostgreSQL配置 + # 修改的PostgreSQL配置(默认主库;DB_DRIVER=mysql 时可改用下方 mysql 服务) postgres: image: paradedb/paradedb:v0.22.2-pg17 container_name: WeKnora-postgres @@ -521,6 +521,32 @@ services: # 添加停机时的优雅退出时间 stop_grace_period: 1m + mysql: + image: mysql:8.4 + container_name: WeKnora-mysql + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-weknora-root-password} + - MYSQL_DATABASE=${DB_NAME} + - MYSQL_USER=${DB_USER} + - MYSQL_PASSWORD=${DB_PASSWORD} + - TZ=${TZ:-Asia/Shanghai} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql-data:/var/lib/mysql + networks: + - WeKnora-network + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"] + interval: 10s + timeout: 10s + retries: 5 + start_period: 30s + restart: unless-stopped + profiles: + - mysql + redis: image: redis:7.0-alpine container_name: WeKnora-redis @@ -1009,6 +1035,7 @@ networks: volumes: postgres-data: + mysql-data: data-files: docreader-tmp: minio_data: diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index 38e12b06c4..03a980e0b1 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -22,7 +22,7 @@ RUN if [ -n "$APK_MIRROR_ARG" ]; then \ apt-get install -y git build-essential libsqlite3-dev # Install migrate tool -RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +RUN go install -tags 'postgres mysql sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest # Copy go mod and sum files COPY go.mod go.sum ./ diff --git a/docs/MySQL.md b/docs/MySQL.md new file mode 100644 index 0000000000..516d8a1473 --- /dev/null +++ b/docs/MySQL.md @@ -0,0 +1,42 @@ +# MySQL 主数据库部署说明 + +WeKnora 现在支持使用 MySQL 作为主数据库(`DB_DRIVER=mysql`)。PostgreSQL 仍然可用;MySQL 模式主要替换业务主库,不再使用 PostgreSQL/ParadeDB 的内置向量检索能力。 + +## 关键限制 + +- `DB_DRIVER=mysql` 时不能再使用 `RETRIEVE_DRIVER=postgres`。 +- 请改用独立检索/向量引擎,例如:`qdrant`、`milvus`、`weaviate`、`elasticsearch_v8`、`opensearch`、`doris` 或 `tencent_vectordb`。 + +## docker-compose 示例 + +`.env` 示例: + +```env +DB_DRIVER=mysql +DB_HOST=mysql +DB_PORT=3306 +DB_USER=weknora +DB_PASSWORD=weknora123!@# +DB_NAME=WeKnora + +# MySQL 模式下请选择非 postgres 的检索引擎 +RETRIEVE_DRIVER=qdrant +QDRANT_HOST=qdrant +QDRANT_PORT=6334 +``` + +启动 MySQL 与示例 Qdrant 检索引擎: + +```bash +docker compose --profile mysql --profile qdrant up -d +``` + +> 现有 Langfuse 集成依然依赖 PostgreSQL,这是 Langfuse 自身要求;它与 WeKnora 主库可分开配置。 + +## 手动迁移 + +```bash +DB_DRIVER=mysql ./scripts/migrate.sh up +``` + +脚本会自动使用 `migrations/mysql` 目录。应用启动时 `AUTO_MIGRATE=true` 也会自动执行相同的 MySQL 迁移。 diff --git a/go.mod b/go.mod index 35593daa61..9433fc25cc 100644 --- a/go.mod +++ b/go.mod @@ -84,6 +84,7 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 diff --git a/go.sum b/go.sum index e9e2894277..383ce98660 100644 --- a/go.sum +++ b/go.sum @@ -3961,6 +3961,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= diff --git a/internal/application/repository/chunk.go b/internal/application/repository/chunk.go index 08723f3b64..6f1b9a97a1 100644 --- a/internal/application/repository/chunk.go +++ b/internal/application/repository/chunk.go @@ -454,7 +454,8 @@ func (r *chunkRepository) UpdateChunks(ctx context.Context, chunks []*types.Chun args = append(args, id) } - isPostgres := r.db.Dialector.Name() == "postgres" + dialect := r.db.Dialector.Name() + isPostgres := dialect == "postgres" var sql string if isPostgres { @@ -476,6 +477,10 @@ func (r *chunkRepository) UpdateChunks(ctx context.Context, chunks []*types.Chun strings.Join(inPlaceholders, ","), ) } else { + nowExpr := "datetime('now')" + if dialect == "mysql" { + nowExpr = "NOW(3)" + } sql = fmt.Sprintf(` UPDATE chunks SET content = CASE %s END, @@ -483,7 +488,7 @@ func (r *chunkRepository) UpdateChunks(ctx context.Context, chunks []*types.Chun tag_id = CASE %s END, flags = CASE %s END, status = CASE %s END, - updated_at = datetime('now') + updated_at = %s WHERE id IN (%s) `, strings.Join(contentCases, " "), @@ -491,6 +496,7 @@ func (r *chunkRepository) UpdateChunks(ctx context.Context, chunks []*types.Chun strings.Join(tagIDCases, " "), strings.Join(flagsCases, " "), strings.Join(statusCases, " "), + nowExpr, strings.Join(inPlaceholders, ","), ) } diff --git a/internal/application/repository/knowledge.go b/internal/application/repository/knowledge.go index 095dcaadac..2db9777df5 100644 --- a/internal/application/repository/knowledge.go +++ b/internal/application/repository/knowledge.go @@ -584,10 +584,17 @@ func (r *knowledgeRepository) FindByMetadataKey( value string, ) (*types.Knowledge, error) { var knowledge types.Knowledge - err := r.db.WithContext(ctx). - Where("tenant_id = ? AND knowledge_base_id = ? AND deleted_at IS NULL", tenantID, kbID). - Where("metadata->>? = ?", key, value). - First(&knowledge).Error + query := r.db.WithContext(ctx). + Where("tenant_id = ? AND knowledge_base_id = ? AND deleted_at IS NULL", tenantID, kbID) + switch r.db.Dialector.Name() { + case "postgres": + query = query.Where("metadata->>? = ?", key, value) + case "mysql": + query = query.Where("JSON_UNQUOTE(JSON_EXTRACT(metadata, ?)) = ?", "$."+key, value) + default: + query = query.Where("json_extract(metadata, ?) = ?", "$."+key, value) + } + err := query.First(&knowledge).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil @@ -621,7 +628,15 @@ func (r *knowledgeRepository) FindByMetadataKeyPrefix( // custom-planned with the actual value, so LIKE 'prefix%' still extracts the // prefix and drives the index. The explicit ESCAPE '\' keeps backslash-escaped // wildcards (e.g. \_) literal on both PostgreSQL and SQLite. - keyExpr := "metadata->>'" + strings.ReplaceAll(key, "'", "''") + "'" + var keyExpr string + switch r.db.Dialector.Name() { + case "postgres": + keyExpr = "metadata->>'" + strings.ReplaceAll(key, "'", "''") + "'" + case "mysql": + keyExpr = "JSON_UNQUOTE(JSON_EXTRACT(metadata, '$." + strings.ReplaceAll(key, "'", "''") + "'))" + default: + keyExpr = "json_extract(metadata, '$." + strings.ReplaceAll(key, "'", "''") + "')" + } err := r.db.WithContext(ctx). Where("tenant_id = ? AND knowledge_base_id = ? AND deleted_at IS NULL", tenantID, kbID). Where(keyExpr+" LIKE ? ESCAPE ?", escaped+"%", `\`). diff --git a/internal/application/repository/message.go b/internal/application/repository/message.go index 22d83ac375..36cadff8f3 100644 --- a/internal/application/repository/message.go +++ b/internal/application/repository/message.go @@ -162,13 +162,17 @@ func (r *messageRepository) SearchMessagesByKeyword( var results []*types.MessageWithSession + likeExpr := "LOWER(messages.content) LIKE LOWER(?)" + if r.db.Dialector.Name() == "postgres" { + likeExpr = "messages.content ILIKE ?" + } query := r.db.WithContext(ctx). Table("messages"). Select("messages.*, sessions.title as session_title"). Joins("INNER JOIN sessions ON sessions.id = messages.session_id AND sessions.deleted_at IS NULL"). Where("sessions.tenant_id = ?", tenantID). Where("messages.deleted_at IS NULL"). - Where("messages.content ILIKE ?", "%"+escapeLikeKeyword(keyword)+"%") + Where(likeExpr, "%"+escapeLikeKeyword(keyword)+"%") if len(sessionIDs) > 0 { query = query.Where("messages.session_id IN ?", sessionIDs) diff --git a/internal/application/repository/model_usage.go b/internal/application/repository/model_usage.go index f8336870ad..9886e07350 100644 --- a/internal/application/repository/model_usage.go +++ b/internal/application/repository/model_usage.go @@ -7,7 +7,8 @@ import ( // scopeKnowledgeBasesByModelID filters knowledge_bases rows that reference // modelID in any model-binding field. func scopeKnowledgeBasesByModelID(db *gorm.DB, modelID string) *gorm.DB { - if db.Dialector.Name() == "postgres" { + switch db.Dialector.Name() { + case "postgres": return db.Where( "embedding_model_id = ? OR summary_model_id = ? OR "+ "image_processing_config->>'model_id' = ? OR "+ @@ -16,21 +17,32 @@ func scopeKnowledgeBasesByModelID(db *gorm.DB, modelID string) *gorm.DB { "wiki_config->>'synthesis_model_id' = ?", modelID, modelID, modelID, modelID, modelID, modelID, ) + case "mysql": + return db.Where( + "embedding_model_id = ? OR summary_model_id = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(image_processing_config, '$.model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(vlm_config, '$.model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(asr_config, '$.model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(wiki_config, '$.synthesis_model_id')) = ?", + modelID, modelID, modelID, modelID, modelID, modelID, + ) + default: + return db.Where( + "embedding_model_id = ? OR summary_model_id = ? OR "+ + "json_extract(image_processing_config, '$.model_id') = ? OR "+ + "json_extract(vlm_config, '$.model_id') = ? OR "+ + "json_extract(asr_config, '$.model_id') = ? OR "+ + "json_extract(wiki_config, '$.synthesis_model_id') = ?", + modelID, modelID, modelID, modelID, modelID, modelID, + ) } - return db.Where( - "embedding_model_id = ? OR summary_model_id = ? OR "+ - "json_extract(image_processing_config, '$.model_id') = ? OR "+ - "json_extract(vlm_config, '$.model_id') = ? OR "+ - "json_extract(asr_config, '$.model_id') = ? OR "+ - "json_extract(wiki_config, '$.synthesis_model_id') = ?", - modelID, modelID, modelID, modelID, modelID, modelID, - ) } // scopeCustomAgentsByModelID filters custom_agents rows whose config JSON // references modelID in any model-binding field. func scopeCustomAgentsByModelID(db *gorm.DB, modelID string) *gorm.DB { - if db.Dialector.Name() == "postgres" { + switch db.Dialector.Name() { + case "postgres": return db.Where( "config->>'model_id' = ? OR config->>'rerank_model_id' = ? OR "+ "config->>'vlm_model_id' = ? OR config->>'asr_model_id' = ? OR "+ @@ -38,14 +50,25 @@ func scopeCustomAgentsByModelID(db *gorm.DB, modelID string) *gorm.DB { "config->'question_suggestions'->'follow_ups'->>'model_id' = ?", modelID, modelID, modelID, modelID, modelID, modelID, ) + case "mysql": + return db.Where( + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.rerank_model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.vlm_model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.asr_model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.query_understand_model_id')) = ? OR "+ + "JSON_UNQUOTE(JSON_EXTRACT(config, '$.question_suggestions.follow_ups.model_id')) = ?", + modelID, modelID, modelID, modelID, modelID, modelID, + ) + default: + return db.Where( + "json_extract(config, '$.model_id') = ? OR "+ + "json_extract(config, '$.rerank_model_id') = ? OR "+ + "json_extract(config, '$.vlm_model_id') = ? OR "+ + "json_extract(config, '$.asr_model_id') = ? OR "+ + "json_extract(config, '$.query_understand_model_id') = ? OR "+ + "json_extract(config, '$.question_suggestions.follow_ups.model_id') = ?", + modelID, modelID, modelID, modelID, modelID, modelID, + ) } - return db.Where( - "json_extract(config, '$.model_id') = ? OR "+ - "json_extract(config, '$.rerank_model_id') = ? OR "+ - "json_extract(config, '$.vlm_model_id') = ? OR "+ - "json_extract(config, '$.asr_model_id') = ? OR "+ - "json_extract(config, '$.query_understand_model_id') = ? OR "+ - "json_extract(config, '$.question_suggestions.follow_ups.model_id') = ?", - modelID, modelID, modelID, modelID, modelID, modelID, - ) } diff --git a/internal/application/repository/organization.go b/internal/application/repository/organization.go index 8aea067654..f085321f60 100644 --- a/internal/application/repository/organization.go +++ b/internal/application/repository/organization.go @@ -91,7 +91,11 @@ func (r *organizationRepository) ListSearchable(ctx context.Context, query strin if query != "" { pattern := "%" + query + "%" // 支持按名称、描述或空间 ID 搜索,便于区分同名空间 - q = q.Where("name ILIKE ? OR description ILIKE ? OR id::text ILIKE ?", pattern, pattern, pattern) + if r.db.Dialector.Name() == "postgres" { + q = q.Where("name ILIKE ? OR description ILIKE ? OR id::text ILIKE ?", pattern, pattern, pattern) + } else { + q = q.Where("LOWER(name) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?) OR LOWER(id) LIKE LOWER(?)", pattern, pattern, pattern) + } } err := q.Order("created_at DESC").Limit(limit).Find(&orgs).Error if err != nil { diff --git a/internal/application/repository/task_queue.go b/internal/application/repository/task_queue.go index 63df641137..54504d4d43 100644 --- a/internal/application/repository/task_queue.go +++ b/internal/application/repository/task_queue.go @@ -361,6 +361,26 @@ func (r *taskPendingOpsRepository) DeleteByScope(ctx context.Context, scope, sco // A missing row returns (0, nil): the caller's ID may have been removed // by a concurrent DeleteByIDs (e.g. dead-letter path), which is benign. func (r *taskPendingOpsRepository) IncrFailCount(ctx context.Context, id int64) (int, error) { + if r.db.Dialector.Name() != "postgres" { + var newCount int + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + res := tx.Model(&types.TaskPendingOp{}). + Where("id = ?", id). + Update("fail_count", gorm.Expr("fail_count + 1")) + if res.Error != nil || res.RowsAffected == 0 { + return res.Error + } + return tx.Model(&types.TaskPendingOp{}). + Select("fail_count"). + Where("id = ?", id). + Scan(&newCount).Error + }) + if err != nil { + return 0, err + } + return newCount, nil + } + var newCount int err := r.db.WithContext(ctx).Raw( `UPDATE task_pending_ops SET fail_count = fail_count + 1 WHERE id = ? RETURNING fail_count`, diff --git a/internal/application/repository/user.go b/internal/application/repository/user.go index b52740c60b..5061615159 100644 --- a/internal/application/repository/user.go +++ b/internal/application/repository/user.go @@ -261,8 +261,12 @@ func (r *userRepository) SearchUsers(ctx context.Context, query string, limit in var users []*types.User searchPattern := "%" + query + "%" + likeExpr := "LOWER(username) LIKE LOWER(?) OR LOWER(email) LIKE LOWER(?)" + if r.db.Dialector.Name() == "postgres" { + likeExpr = "username ILIKE ? OR email ILIKE ?" + } dbQuery := r.db.WithContext(ctx). - Where("username ILIKE ? OR email ILIKE ?", searchPattern, searchPattern). + Where(likeExpr, searchPattern, searchPattern). Where("is_active = ?", true). Order("username ASC") diff --git a/internal/application/repository/wiki_page.go b/internal/application/repository/wiki_page.go index f3659adbd8..b5dfd0a452 100644 --- a/internal/application/repository/wiki_page.go +++ b/internal/application/repository/wiki_page.go @@ -31,19 +31,51 @@ func NewWikiPageRepository(db *gorm.DB) interfaces.WikiPageRepository { } func (r *wikiPageRepository) wikiCategoryRankOrder() string { - if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" { - return "CASE WHEN COALESCE(json_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC" + if r.db != nil && r.db.Dialector != nil { + switch r.db.Dialector.Name() { + case "sqlite": + return "CASE WHEN COALESCE(json_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC" + case "mysql": + return "CASE WHEN COALESCE(JSON_LENGTH(category_path), 0) > 0 THEN 0 ELSE 1 END ASC" + } } return "CASE WHEN COALESCE(jsonb_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC" } func (r *wikiPageRepository) wikiEmptyInLinksPredicate() string { - if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" { - return "(in_links IS NULL OR json_array_length(in_links) = 0)" + if r.db != nil && r.db.Dialector != nil { + switch r.db.Dialector.Name() { + case "sqlite": + return "(in_links IS NULL OR json_array_length(in_links) = 0)" + case "mysql": + return "(in_links IS NULL OR JSON_LENGTH(in_links) = 0)" + } } return "(in_links IS NULL OR in_links = '[]'::JSONB)" } +func (r *wikiPageRepository) wikiSourceRefsContainsPredicate() string { + if r.db != nil && r.db.Dialector != nil { + switch r.db.Dialector.Name() { + case "mysql": + return "JSON_CONTAINS(source_refs, ?)" + case "sqlite": + return "EXISTS (SELECT 1 FROM json_each(source_refs) WHERE value IN (SELECT value FROM json_each(?)))" + } + } + return "source_refs @> ?::jsonb" +} + +func (r *wikiPageRepository) wikiSourceRefsTextLikePredicate() string { + if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" { + return "source_refs::text LIKE ?" + } + if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "mysql" { + return "CAST(source_refs AS CHAR) LIKE ?" + } + return "source_refs LIKE ?" +} + // Create inserts a new wiki page record func (r *wikiPageRepository) Create(ctx context.Context, page *types.WikiPage) error { return r.db.WithContext(ctx).Create(page).Error @@ -319,12 +351,26 @@ func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRe query = query.Where("status = ?", req.Status) } if req.Query != "" { - // Use PostgreSQL full-text search + ILIKE for aliases - query = query.Where( - "(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, '')) @@ plainto_tsquery('simple', ?) OR aliases::text ILIKE ?)", - req.Query, - "%"+req.Query+"%", - ) + like := "%" + req.Query + "%" + switch r.db.Dialector.Name() { + case "postgres": + // Use PostgreSQL full-text search + ILIKE for aliases + query = query.Where( + "(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, '')) @@ plainto_tsquery('simple', ?) OR aliases::text ILIKE ?)", + req.Query, + like, + ) + case "mysql": + query = query.Where( + "(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) OR CAST(aliases AS CHAR) LIKE ?)", + like, like, like, + ) + default: + query = query.Where( + "(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?) OR aliases LIKE ?)", + like, like, like, + ) + } } // Directory filters are pushed to SQL so the DB does the counting and // pagination instead of loading every page of the type into memory. `depth` @@ -488,7 +534,7 @@ func (r *wikiPageRepository) ListBySourceRef(ctx context.Context, kbID string, s var pages []*types.WikiPage if err := r.db.WithContext(ctx). - Where("knowledge_base_id = ? AND (source_refs @> ?::jsonb OR source_refs::text LIKE ?)", + Where("knowledge_base_id = ? AND ("+r.wikiSourceRefsContainsPredicate()+" OR "+r.wikiSourceRefsTextLikePredicate()+")", kbID, string(needle), likePattern, @@ -526,7 +572,7 @@ func (r *wikiPageRepository) ListSlugsBySourceRef(ctx context.Context, kbID stri var slugs []string if err := r.db.WithContext(ctx). Model(&types.WikiPage{}). - Where("knowledge_base_id = ? AND (source_refs @> ?::jsonb OR source_refs::text LIKE ?)", + Where("knowledge_base_id = ? AND ("+r.wikiSourceRefsContainsPredicate()+" OR "+r.wikiSourceRefsTextLikePredicate()+")", kbID, string(needle), likePattern, @@ -827,7 +873,7 @@ func (r *wikiPageRepository) ListSummariesByKnowledgeIDs( if err != nil { return nil, fmt.Errorf("marshal kid needle: %w", err) } - clauses = append(clauses, "source_refs @> ?::jsonb") + clauses = append(clauses, r.wikiSourceRefsContainsPredicate()) args = append(args, string(needle)) prefix, err := json.Marshal(kid + "|") @@ -838,7 +884,7 @@ func (r *wikiPageRepository) ListSummariesByKnowledgeIDs( if len(prefixStr) >= 2 && prefixStr[len(prefixStr)-1] == '"' { prefixStr = prefixStr[:len(prefixStr)-1] } - clauses = append(clauses, "source_refs::text LIKE ?") + clauses = append(clauses, r.wikiSourceRefsTextLikePredicate()) args = append(args, "%"+escapeLikePattern(prefixStr)+"%") } if len(clauses) == 0 { diff --git a/internal/application/service/storagebackend.go b/internal/application/service/storagebackend.go index 0676cf52e1..15b0f0bde5 100644 --- a/internal/application/service/storagebackend.go +++ b/internal/application/service/storagebackend.go @@ -132,7 +132,7 @@ func (s *StorageBackendService) Delete(ctx context.Context, tenantID uint64, id return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var backend types.StorageBackend query := tx.Where("tenant_id = ? AND id = ?", tenantID, id) - if tx.Dialector.Name() == "postgres" { + if tx.Dialector.Name() == "postgres" || tx.Dialector.Name() == "mysql" { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.First(&backend).Error; err != nil { @@ -178,7 +178,7 @@ func (s *StorageBackendService) SetDefault(ctx context.Context, tenantID uint64, return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var backend types.StorageBackend query := tx.Where("tenant_id = ? AND id = ?", tenantID, id) - if tx.Dialector.Name() == "postgres" { + if tx.Dialector.Name() == "postgres" || tx.Dialector.Name() == "mysql" { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.First(&backend).Error; err != nil { diff --git a/internal/application/service/vectorstore.go b/internal/application/service/vectorstore.go index 6ec25587cd..dd0334aad2 100644 --- a/internal/application/service/vectorstore.go +++ b/internal/application/service/vectorstore.go @@ -247,11 +247,11 @@ func (s *vectorStoreService) unregisterSafely(ctx context.Context, storeID strin } } -// isPostgres reports whether the active GORM dialector is PostgreSQL. -// Used to gate dialect-specific clauses (e.g., SELECT FOR UPDATE) that -// SQLite would either ignore (recent versions) or fail to compile on. +// isPostgres reports whether the active GORM dialector supports the row-locking +// clauses used by the PostgreSQL path. MySQL/InnoDB supports the same GORM +// Locking clause; SQLite would either ignore it or fail to compile on. func (s *vectorStoreService) isPostgres(db *gorm.DB) bool { - return db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" + return db != nil && db.Dialector != nil && (db.Dialector.Name() == "postgres" || db.Dialector.Name() == "mysql") } // SaveDetectedVersion updates the connection_config.version for a stored vector store. diff --git a/internal/container/container.go b/internal/container/container.go index 8a9265f5ee..50af35eb5f 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -21,7 +21,7 @@ import ( _ "github.com/duckdb/duckdb-go/v2" esv7 "github.com/elastic/go-elasticsearch/v7" "github.com/elastic/go-elasticsearch/v8" - _ "github.com/go-sql-driver/mysql" // 给 Doris (database/sql) 注册 MySQL 协议驱动 + mysqlcfg "github.com/go-sql-driver/mysql" // 也会给 Doris (database/sql) 注册 MySQL 协议驱动 "github.com/milvus-io/milvus/client/v2/milvusclient" "github.com/neo4j/neo4j-go-driver/v6/neo4j" "github.com/panjf2000/ants/v2" @@ -29,6 +29,7 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/dig" "google.golang.org/grpc" + gormmysql "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -561,7 +562,7 @@ func initRedisClient() (*redis.Client, error) { // initDatabase initializes database connection // Creates and configures database connection based on environment configuration -// Supports multiple database backends (PostgreSQL) +// Supports multiple database backends (PostgreSQL, MySQL, SQLite) // Parameters: // - cfg: Application configuration // @@ -574,6 +575,13 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) { var sqliteDBPath string switch os.Getenv("DB_DRIVER") { case "postgres": + // Guard against a common misconfiguration after switching the main + // database to MySQL: the legacy postgres retriever needs PostgreSQL + // extensions/tables and cannot run on top of a MySQL GORM connection. + // Postgres primary DB remains supported for deployments that still use it. + if strings.Contains(","+os.Getenv("RETRIEVE_DRIVER")+",", ",postgres,") { + logger.Infof(context.Background(), "PostgreSQL retrieve engine enabled with PostgreSQL primary database") + } // DSN for GORM (key-value format) gormDSN := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=UTC", @@ -616,6 +624,36 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) { os.Getenv("DB_PORT"), os.Getenv("DB_NAME"), ) + case "mysql": + retrieveDriver := strings.Split(os.Getenv("RETRIEVE_DRIVER"), ",") + if slices.Contains(retrieveDriver, "postgres") { + return nil, fmt.Errorf("RETRIEVE_DRIVER=postgres requires DB_DRIVER=postgres; use qdrant, milvus, weaviate, elasticsearch/opensearch, doris, or tencent_vectordb with DB_DRIVER=mysql") + } + + cfg := mysqlcfg.NewConfig() + cfg.User = os.Getenv("DB_USER") + cfg.Passwd = os.Getenv("DB_PASSWORD") + cfg.Net = "tcp" + cfg.Addr = fmt.Sprintf("%s:%s", os.Getenv("DB_HOST"), os.Getenv("DB_PORT")) + cfg.DBName = os.Getenv("DB_NAME") + cfg.ParseTime = true + cfg.Loc = time.UTC + cfg.Params = map[string]string{ + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "multiStatements": "true", + "interpolateParams": "true", + } + dialector = gormmysql.Open(cfg.FormatDSN()) + + migrateDSN = fmt.Sprintf("mysql://%s@tcp(%s:%s)/%s?multiStatements=true&parseTime=true", + url.UserPassword(os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD")).String(), + os.Getenv("DB_HOST"), + os.Getenv("DB_PORT"), + url.PathEscape(os.Getenv("DB_NAME")), + ) + logger.Infof(context.Background(), "DB Config: driver=mysql user=%s host=%s port=%s dbname=%s", + os.Getenv("DB_USER"), os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_NAME")) case "sqlite": dbPath := os.Getenv("DB_PATH") if dbPath == "" { @@ -650,10 +688,10 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) { // different name (e.g., a wrapper dialect for managed PG) would silently // fall back to the SQLite path, dropping the row-level X-lock. Catching // the mismatch at startup is loud and inexpensive. - if name := db.Dialector.Name(); name != "postgres" && name != "sqlite" { + if name := db.Dialector.Name(); name != "postgres" && name != "mysql" && name != "sqlite" { return nil, fmt.Errorf( - "unsupported gorm dialector %q; expected postgres or sqlite "+ - "(see vectorStoreService.isPostgres for impact)", name) + "unsupported gorm dialector %q; expected postgres, mysql, or sqlite "+ + "(see dialect-specific repository code for impact)", name) } if os.Getenv("DB_DRIVER") == "sqlite" { @@ -733,8 +771,15 @@ func resolveStorageProviderPending(db *gorm.DB) { } storageType = strings.ToLower(storageType) + providerPredicate := "storage_provider_config IS NOT NULL AND storage_provider_config->>'provider' = '__pending_env__'" + switch db.Dialector.Name() { + case "mysql": + providerPredicate = "storage_provider_config IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(storage_provider_config, '$.provider')) = '__pending_env__'" + case "sqlite": + providerPredicate = "storage_provider_config IS NOT NULL AND json_extract(storage_provider_config, '$.provider') = '__pending_env__'" + } result := db.Exec( - `UPDATE knowledge_bases SET storage_provider_config = ? WHERE storage_provider_config IS NOT NULL AND storage_provider_config->>'provider' = '__pending_env__'`, + `UPDATE knowledge_bases SET storage_provider_config = ? WHERE `+providerPredicate, fmt.Sprintf(`{"provider":"%s"}`, storageType), ) if result.Error != nil { diff --git a/internal/database/migration.go b/internal/database/migration.go index 48620fad8f..caffb7a4a7 100644 --- a/internal/database/migration.go +++ b/internal/database/migration.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "fmt" + "net/url" "os" "strings" "sync" "github.com/Tencent/WeKnora/internal/logger" "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/mysql" _ "github.com/golang-migrate/migrate/v4/database/postgres" sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" @@ -106,6 +108,8 @@ func RunMigrationsWithOptions(dsn string, opts MigrationOptions) error { migrationsPath := "file://migrations/versioned" if strings.HasPrefix(dsn, "sqlite3://") { migrationsPath = "file://migrations/sqlite" + } else if strings.HasPrefix(dsn, "mysql://") { + migrationsPath = "file://migrations/mysql" } var m *migrate.Migrate @@ -300,6 +304,7 @@ func recoverFromDirtyState(ctx context.Context, m *migrate.Migrate, dirtyVersion // GetMigrationVersion returns the current migration version func GetMigrationVersion() (uint, bool, error) { + driver := os.Getenv("DB_DRIVER") dbURL := fmt.Sprintf( "postgres://%s:%s@%s:%s/%s?sslmode=disable", os.Getenv("DB_USER"), @@ -308,8 +313,21 @@ func GetMigrationVersion() (uint, bool, error) { os.Getenv("DB_PORT"), os.Getenv("DB_NAME"), ) - migrationsPath := "file://migrations/versioned" + switch driver { + case "mysql": + dbURL = fmt.Sprintf("mysql://%s@tcp(%s:%s)/%s?multiStatements=true&parseTime=true", + url.UserPassword(os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD")).String(), + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), url.PathEscape(os.Getenv("DB_NAME"))) + migrationsPath = "file://migrations/mysql" + case "sqlite": + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./data/weknora.db" + } + dbURL = "sqlite3://" + dbPath + migrationsPath = "file://migrations/sqlite" + } m, err := migrate.New(migrationsPath, dbURL) if err != nil { diff --git a/migrations/mysql/00-init-db.sql b/migrations/mysql/00-init-db.sql deleted file mode 100644 index a4c7aec608..0000000000 --- a/migrations/mysql/00-init-db.sql +++ /dev/null @@ -1,229 +0,0 @@ -DROP TABLE IF EXISTS tenants; -DROP TABLE IF EXISTS models; -DROP TABLE IF EXISTS knowledge_bases; -DROP TABLE IF EXISTS knowledges; -DROP TABLE IF EXISTS sessions; -DROP TABLE IF EXISTS messages; -DROP TABLE IF EXISTS chunks; - -CREATE TABLE tenants ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - retriever_engines JSON NOT NULL, - status VARCHAR(50) DEFAULT 'active', - business VARCHAR(255) NOT NULL, - storage_quota BIGINT NOT NULL DEFAULT 10737418240, - storage_used BIGINT NOT NULL DEFAULT 0, - agent_config JSON DEFAULT NULL COMMENT 'Tenant-level agent configuration in JSON format', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=10000; - -CREATE TABLE models ( - id VARCHAR(64) PRIMARY KEY, - tenant_id INT NOT NULL, - name VARCHAR(255) NOT NULL, - display_name VARCHAR(255) NOT NULL DEFAULT '', - type VARCHAR(50) NOT NULL, - source VARCHAR(50) NOT NULL, - description TEXT, - parameters JSON NOT NULL, - is_default BOOLEAN NOT NULL DEFAULT FALSE, - status VARCHAR(50) NOT NULL DEFAULT 'active', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_models_tenant_source_type ON models(tenant_id, source, type); - -CREATE TABLE knowledge_bases ( - id VARCHAR(36) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - tenant_id INT NOT NULL, - chunking_config JSON NOT NULL, - image_processing_config JSON NOT NULL, - embedding_model_id VARCHAR(64) NOT NULL, - summary_model_id VARCHAR(64) NOT NULL, - rerank_model_id VARCHAR(64) NOT NULL, - cos_config JSON NOT NULL, - vlm_config JSON NOT NULL, - extract_config JSON NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_knowledge_bases_tenant_name ON knowledge_bases(tenant_id, name); - -CREATE TABLE knowledges ( - id VARCHAR(36) PRIMARY KEY, - tenant_id INT NOT NULL, - knowledge_base_id VARCHAR(36) NOT NULL, - type VARCHAR(50) NOT NULL, - title VARCHAR(255) NOT NULL, - description TEXT, - source VARCHAR(2048) NOT NULL, - parse_status VARCHAR(50) NOT NULL DEFAULT 'unprocessed', - enable_status VARCHAR(50) NOT NULL DEFAULT 'enabled', - embedding_model_id VARCHAR(64), - file_name VARCHAR(255), - file_type VARCHAR(50), - file_size BIGINT, - file_path TEXT, - file_hash VARCHAR(64), - storage_size BIGINT NOT NULL DEFAULT 0, - metadata JSON, - custom_metadata JSON NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL, - processed_at TIMESTAMP, - error_message TEXT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_knowledges_tenant_id ON knowledges(tenant_id, knowledge_base_id); - -CREATE TABLE sessions ( - id VARCHAR(36) PRIMARY KEY, - tenant_id INTEGER NOT NULL, - title VARCHAR(255), - description TEXT, - knowledge_base_id VARCHAR(36), - max_rounds INT NOT NULL DEFAULT 5, - enable_rewrite BOOLEAN NOT NULL DEFAULT TRUE, - fallback_strategy VARCHAR(255) NOT NULL DEFAULT 'fixed', - fallback_response VARCHAR(255) NOT NULL DEFAULT '很抱歉,我暂时无法回答这个问题。', - keyword_threshold FLOAT NOT NULL DEFAULT 0.5, - vector_threshold FLOAT NOT NULL DEFAULT 0.5, - rerank_model_id VARCHAR(64), - embedding_top_k INTEGER NOT NULL DEFAULT 10, - rerank_top_k INTEGER NOT NULL DEFAULT 10, - rerank_threshold FLOAT NOT NULL DEFAULT 0.65, - summary_model_id VARCHAR(64), - summary_parameters JSON NOT NULL, - agent_config JSON DEFAULT NULL COMMENT 'Session-level agent configuration in JSON format', - context_config JSON DEFAULT NULL COMMENT 'LLM context management configuration (separate from message storage)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_sessions_tenant_id ON sessions(tenant_id); - -CREATE TABLE messages ( - id VARCHAR(36) PRIMARY KEY, - request_id VARCHAR(36) NOT NULL, - session_id VARCHAR(36) NOT NULL, - role VARCHAR(50) NOT NULL, - content TEXT NOT NULL, - knowledge_references JSON NOT NULL, - agent_steps JSON DEFAULT NULL COMMENT 'Agent execution steps (reasoning process and tool calls)', - is_completed BOOLEAN NOT NULL DEFAULT FALSE, - agent_id VARCHAR(36) NOT NULL DEFAULT '', - agent_tenant_id INTEGER NOT NULL DEFAULT 0, - model_id VARCHAR(64) NOT NULL DEFAULT '', - execution_context JSON NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_messages_session_role ON messages(session_id, role); -CREATE INDEX idx_messages_agent_id ON messages(agent_id); - -CREATE TABLE message_suggestion_sets ( - id VARCHAR(36) PRIMARY KEY, - tenant_id INTEGER NOT NULL, - session_id VARCHAR(36) NOT NULL, - assistant_message_id VARCHAR(36) NOT NULL, - agent_id VARCHAR(36) NOT NULL DEFAULT '', - agent_tenant_id INTEGER NOT NULL DEFAULT 0, - placement VARCHAR(32) NOT NULL, - config_hash VARCHAR(64) NOT NULL, - locale VARCHAR(16) NOT NULL DEFAULT '', - status VARCHAR(16) NOT NULL, - allow_regenerate BOOLEAN NOT NULL DEFAULT FALSE, - suppression_reason VARCHAR(64) NOT NULL DEFAULT '', - questions JSON NOT NULL, - model_id VARCHAR(64) NOT NULL DEFAULT '', - prompt_tokens INTEGER NOT NULL DEFAULT 0, - completion_tokens INTEGER NOT NULL DEFAULT 0, - latency_ms BIGINT NOT NULL DEFAULT 0, - error_code VARCHAR(64) NOT NULL DEFAULT '', - lease_until TIMESTAMP NULL DEFAULT NULL, - generated_at TIMESTAMP NULL DEFAULT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY idx_message_suggestion_sets_cache_key - (tenant_id, assistant_message_id, placement, config_hash, locale), - KEY idx_message_suggestion_sets_session (tenant_id, session_id, created_at), - KEY idx_message_suggestion_sets_status (status, lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE message_suggestion_events ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - tenant_id INTEGER NOT NULL, - session_id VARCHAR(36) NOT NULL, - suggestion_set_id VARCHAR(36) NOT NULL, - question_id VARCHAR(64) NOT NULL DEFAULT '', - event_type VARCHAR(32) NOT NULL, - actor_id VARCHAR(512) NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY idx_message_suggestion_events_set (suggestion_set_id, created_at), - KEY idx_message_suggestion_events_session (tenant_id, session_id, created_at), - KEY idx_message_suggestion_events_type (event_type, created_at), - CONSTRAINT fk_message_suggestion_events_set - FOREIGN KEY (suggestion_set_id) REFERENCES message_suggestion_sets(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE TABLE chunks ( - id VARCHAR(36) PRIMARY KEY, - tenant_id INTEGER NOT NULL, - knowledge_base_id VARCHAR(36) NOT NULL, - knowledge_id VARCHAR(36) NOT NULL, - content TEXT NOT NULL, - source_content TEXT NOT NULL, - content_revision INT NOT NULL DEFAULT 0, - index_status VARCHAR(16) NOT NULL DEFAULT 'ready', - last_editor_id VARCHAR(64) NOT NULL DEFAULT '', - context_header TEXT NOT NULL, - chunk_index INTEGER NOT NULL, - is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - start_at INTEGER NOT NULL, - end_at INTEGER NOT NULL, - pre_chunk_id VARCHAR(36), - next_chunk_id VARCHAR(36), - chunk_type VARCHAR(20) NOT NULL DEFAULT 'text', - parent_chunk_id VARCHAR(36), - image_info TEXT, - relation_chunks JSON, - indirect_relation_chunks JSON, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - deleted_at TIMESTAMP NULL DEFAULT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -CREATE INDEX idx_chunks_tenant_knowledge ON chunks(tenant_id, knowledge_id); -CREATE INDEX idx_chunks_parent_id ON chunks(parent_chunk_id); -CREATE INDEX idx_chunks_chunk_type ON chunks(chunk_type); - -CREATE TABLE chunk_revisions ( - id VARCHAR(36) PRIMARY KEY, - tenant_id BIGINT NOT NULL, - knowledge_base_id VARCHAR(36) NOT NULL, - knowledge_id VARCHAR(36) NOT NULL, - chunk_id VARCHAR(36) NOT NULL, - revision INT NOT NULL, - content TEXT NOT NULL, - is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - editor_id VARCHAR(64) NOT NULL DEFAULT '', - edit_source VARCHAR(16) NOT NULL DEFAULT 'user', - edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY idx_chunk_revisions_chunk_revision (chunk_id, revision), - KEY idx_chunk_revisions_tenant_chunk (tenant_id, chunk_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/mysql/000000_init.down.sql b/migrations/mysql/000000_init.down.sql new file mode 100644 index 0000000000..4bdb6ae81e --- /dev/null +++ b/migrations/mysql/000000_init.down.sql @@ -0,0 +1,51 @@ +DROP TABLE IF EXISTS knowledge_tag_relations; +DROP TABLE IF EXISTS knowledge_processing_spans; +DROP TABLE IF EXISTS system_settings; +DROP TABLE IF EXISTS task_dead_letters; +DROP TABLE IF EXISTS task_pending_ops; +DROP TABLE IF EXISTS wiki_page_revisions; +DROP TABLE IF EXISTS wiki_page_issues; +DROP TABLE IF EXISTS wiki_folders; +DROP TABLE IF EXISTS wiki_pages; +DROP TABLE IF EXISTS temporary_documents; +DROP TABLE IF EXISTS tenant_api_keys; +DROP TABLE IF EXISTS resource_access_grants; +DROP TABLE IF EXISTS resource_bindings; +DROP TABLE IF EXISTS resources; +DROP TABLE IF EXISTS storage_backends; +DROP TABLE IF EXISTS vector_stores; +DROP TABLE IF EXISTS web_search_providers; +DROP TABLE IF EXISTS sync_logs; +DROP TABLE IF EXISTS data_sources; +DROP TABLE IF EXISTS embed_channels; +DROP TABLE IF EXISTS im_channels; +DROP TABLE IF EXISTS im_channel_sessions; +DROP TABLE IF EXISTS tenant_disabled_shared_agents; +DROP TABLE IF EXISTS agent_shares; +DROP TABLE IF EXISTS organization_join_requests; +DROP TABLE IF EXISTS kb_shares; +DROP TABLE IF EXISTS organization_tenant_members; +DROP TABLE IF EXISTS organizations; +DROP TABLE IF EXISTS custom_agents; +DROP TABLE IF EXISTS mcp_oauth_tokens; +DROP TABLE IF EXISTS mcp_oauth_clients; +DROP TABLE IF EXISTS mcp_tool_approvals; +DROP TABLE IF EXISTS mcp_services; +DROP TABLE IF EXISTS knowledge_tags; +DROP TABLE IF EXISTS tenant_invitations; +DROP TABLE IF EXISTS user_kb_pins; +DROP TABLE IF EXISTS user_resource_favorites; +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS tenant_members; +DROP TABLE IF EXISTS auth_tokens; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS chunk_revisions; +DROP TABLE IF EXISTS chunks; +DROP TABLE IF EXISTS message_suggestion_events; +DROP TABLE IF EXISTS message_suggestion_sets; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS knowledges; +DROP TABLE IF EXISTS knowledge_bases; +DROP TABLE IF EXISTS models; +DROP TABLE IF EXISTS tenants; diff --git a/migrations/mysql/000000_init.up.sql b/migrations/mysql/000000_init.up.sql new file mode 100644 index 0000000000..785d24ea4d --- /dev/null +++ b/migrations/mysql/000000_init.up.sql @@ -0,0 +1,1181 @@ +-- MySQL schema for WeKnora (consolidated from PostgreSQL/SQLite migrations) + +CREATE TABLE IF NOT EXISTS tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description LONGTEXT, + retriever_engines LONGTEXT NOT NULL DEFAULT ('[]'), + status VARCHAR(50) DEFAULT 'active', + business VARCHAR(255) NOT NULL, + storage_quota BIGINT NOT NULL DEFAULT 10737418240, + storage_used BIGINT NOT NULL DEFAULT 0, + agent_config LONGTEXT DEFAULT NULL, + context_config LONGTEXT, + conversation_config LONGTEXT, + web_search_config LONGTEXT DEFAULT NULL, + parser_engine_config LONGTEXT DEFAULT NULL, + storage_engine_config LONGTEXT DEFAULT NULL, + default_storage_backend_id VARCHAR(36), + credentials LONGTEXT DEFAULT NULL, + api_principal_config LONGTEXT DEFAULT NULL, + chat_history_config LONGTEXT, + retrieval_config LONGTEXT, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB AUTO_INCREMENT=10000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_tenants_status ON tenants(status); + +CREATE TABLE IF NOT EXISTS models ( + id VARCHAR(64) PRIMARY KEY, + tenant_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL DEFAULT '', + type VARCHAR(50) NOT NULL, + source VARCHAR(50) NOT NULL, + description LONGTEXT, + parameters LONGTEXT NOT NULL, + is_default TINYINT(1) NOT NULL DEFAULT 0, + is_builtin TINYINT(1) NOT NULL DEFAULT 0, + managed_by VARCHAR(32) NOT NULL DEFAULT '', + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_models_type ON models(type); +CREATE INDEX idx_models_source ON models(source); +CREATE INDEX idx_models_is_builtin ON models(is_builtin); +CREATE INDEX idx_models_managed_by ON models(managed_by); + +CREATE TABLE IF NOT EXISTS knowledge_bases ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description LONGTEXT, + tenant_id INT NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'document', + chunking_config LONGTEXT NOT NULL DEFAULT ('{"chunk_size": 512, "chunk_overlap": 50, "split_markers": ["\n\n", "\n", "。"], "keep_separator": true}'), + image_processing_config LONGTEXT NOT NULL DEFAULT ('{"enable_multimodal": false, "model_id": ""}'), + embedding_model_id VARCHAR(64) NOT NULL, + summary_model_id VARCHAR(64) NOT NULL, + cos_config LONGTEXT NOT NULL DEFAULT ('{}'), + storage_provider_config LONGTEXT DEFAULT NULL, + vlm_config LONGTEXT NOT NULL DEFAULT ('{}'), + extract_config LONGTEXT NULL DEFAULT NULL, + faq_config LONGTEXT, + question_generation_config LONGTEXT NULL, + is_temporary TINYINT(1) NOT NULL DEFAULT 0, + is_pinned INT NOT NULL DEFAULT 0, + pinned_at DATETIME(3) NULL, + asr_config LONGTEXT, + vector_store_id VARCHAR(36), + storage_backend_id VARCHAR(36), + creator_id VARCHAR(36), + wiki_config LONGTEXT, + indexing_strategy LONGTEXT DEFAULT ('{"vector_enabled":true,"keyword_enabled":true,"wiki_enabled":false,"graph_enabled":false}'), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_knowledge_bases_tenant_id ON knowledge_bases(tenant_id); +CREATE INDEX idx_knowledge_bases_tenant_vector_store + ON knowledge_bases(tenant_id, vector_store_id); +CREATE INDEX idx_knowledge_bases_storage_backend + ON knowledge_bases(tenant_id, storage_backend_id); +CREATE INDEX idx_knowledge_bases_tenant_creator + ON knowledge_bases(tenant_id, creator_id); + +CREATE TABLE IF NOT EXISTS knowledges ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description LONGTEXT, + source VARCHAR(2048) NOT NULL, + parse_status VARCHAR(50) NOT NULL DEFAULT 'unprocessed', + enable_status VARCHAR(50) NOT NULL DEFAULT 'enabled', + embedding_model_id VARCHAR(64), + file_name VARCHAR(255), + file_type VARCHAR(50), + file_size BIGINT, + file_path LONGTEXT, + file_hash VARCHAR(64), + storage_size BIGINT NOT NULL DEFAULT 0, + metadata LONGTEXT, + custom_metadata LONGTEXT NOT NULL DEFAULT ('{}'), + tag_id VARCHAR(36), + pending_subtasks_count INT NOT NULL DEFAULT 0, + summary_status VARCHAR(32) DEFAULT 'none', + last_faq_import_result LONGTEXT DEFAULT NULL, + channel VARCHAR(50) NOT NULL DEFAULT 'web', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + processed_at DATETIME(3), + error_message LONGTEXT, + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_knowledges_tenant_id ON knowledges(tenant_id); +CREATE INDEX idx_knowledges_base_id ON knowledges(knowledge_base_id); +CREATE INDEX idx_knowledges_parse_status ON knowledges(parse_status); +CREATE INDEX idx_knowledges_enable_status ON knowledges(enable_status); +CREATE INDEX idx_knowledges_tag ON knowledges(tag_id); +CREATE INDEX idx_knowledges_summary_status ON knowledges(summary_status); + +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + title VARCHAR(255), + description LONGTEXT, + knowledge_base_id VARCHAR(36), + max_rounds INT NOT NULL DEFAULT 5, + enable_rewrite TINYINT(1) NOT NULL DEFAULT 1, + fallback_strategy VARCHAR(255) NOT NULL DEFAULT 'fixed', + fallback_response LONGTEXT NOT NULL DEFAULT ('很抱歉,我暂时无法回答这个问题。'), + keyword_threshold FLOAT NOT NULL DEFAULT 0.5, + vector_threshold FLOAT NOT NULL DEFAULT 0.5, + rerank_model_id VARCHAR(64), + embedding_top_k INT NOT NULL DEFAULT 10, + rerank_top_k INT NOT NULL DEFAULT 10, + rerank_threshold FLOAT NOT NULL DEFAULT 0.65, + summary_model_id VARCHAR(64), + summary_parameters LONGTEXT NOT NULL DEFAULT ('{}'), + agent_config LONGTEXT DEFAULT NULL, + context_config LONGTEXT DEFAULT NULL, + agent_id VARCHAR(36), + user_id VARCHAR(512), + is_pinned TINYINT(1) NOT NULL DEFAULT 0, + pinned_at DATETIME(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_sessions_tenant_id ON sessions(tenant_id); +CREATE INDEX idx_sessions_agent_id ON sessions(agent_id); +CREATE INDEX idx_sessions_tenant_user_pin + ON sessions (tenant_id, user_id, is_pinned, pinned_at, updated_at); + +CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR(36) PRIMARY KEY, + request_id VARCHAR(36) NOT NULL, + session_id VARCHAR(36) NOT NULL, + role VARCHAR(50) NOT NULL, + content LONGTEXT NOT NULL, + rendered_content LONGTEXT NOT NULL DEFAULT (''), + knowledge_references LONGTEXT NOT NULL DEFAULT ('[]'), + agent_steps LONGTEXT DEFAULT NULL, + mentioned_items LONGTEXT DEFAULT ('[]'), + images LONGTEXT DEFAULT ('[]'), + attachments LONGTEXT DEFAULT ('[]'), + is_completed TINYINT(1) NOT NULL DEFAULT 0, + is_fallback TINYINT(1) NOT NULL DEFAULT 0, + channel VARCHAR(50) NOT NULL DEFAULT '', + agent_id VARCHAR(36) NOT NULL DEFAULT '', + agent_tenant_id INT NOT NULL DEFAULT 0, + model_id VARCHAR(64) NOT NULL DEFAULT '', + execution_context LONGTEXT NOT NULL DEFAULT ('{}'), + agent_duration_ms INT DEFAULT 0, + knowledge_id VARCHAR(36), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_messages_session_id ON messages(session_id); +CREATE INDEX idx_messages_knowledge_id ON messages(knowledge_id); +CREATE INDEX idx_messages_agent_id ON messages(agent_id); + +CREATE TABLE IF NOT EXISTS message_suggestion_sets ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + session_id VARCHAR(36) NOT NULL, + assistant_message_id VARCHAR(36) NOT NULL, + agent_id VARCHAR(36) NOT NULL DEFAULT '', + agent_tenant_id INT NOT NULL DEFAULT 0, + placement VARCHAR(32) NOT NULL, + config_hash VARCHAR(64) NOT NULL, + locale VARCHAR(16) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL, + allow_regenerate TINYINT(1) NOT NULL DEFAULT 0, + suppression_reason VARCHAR(64) NOT NULL DEFAULT '', + questions LONGTEXT NOT NULL DEFAULT ('[]'), + model_id VARCHAR(64) NOT NULL DEFAULT '', + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + error_code VARCHAR(64) NOT NULL DEFAULT '', + lease_until DATETIME(3), + generated_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE UNIQUE INDEX idx_message_suggestion_sets_cache_key + ON message_suggestion_sets(tenant_id, assistant_message_id, placement, config_hash, locale); +CREATE INDEX idx_message_suggestion_sets_session + ON message_suggestion_sets(tenant_id, session_id, created_at); +CREATE INDEX idx_message_suggestion_sets_status + ON message_suggestion_sets(status, lease_until); + +CREATE TABLE IF NOT EXISTS message_suggestion_events ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + session_id VARCHAR(36) NOT NULL, + suggestion_set_id VARCHAR(36) NOT NULL, + question_id VARCHAR(64) NOT NULL DEFAULT '', + event_type VARCHAR(32) NOT NULL, + actor_id VARCHAR(512) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_message_suggestion_events_set + ON message_suggestion_events(suggestion_set_id, created_at); +CREATE INDEX idx_message_suggestion_events_session + ON message_suggestion_events(tenant_id, session_id, created_at); +CREATE INDEX idx_message_suggestion_events_type + ON message_suggestion_events(event_type, created_at); + +CREATE TABLE IF NOT EXISTS chunks ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + knowledge_id VARCHAR(36) NOT NULL, + content LONGTEXT NOT NULL, + source_content LONGTEXT NOT NULL DEFAULT (''), + content_revision INT NOT NULL DEFAULT 0, + index_status VARCHAR(16) NOT NULL DEFAULT 'ready', + last_editor_id VARCHAR(64) NOT NULL DEFAULT '', + context_header LONGTEXT NOT NULL DEFAULT (''), + chunk_index INT NOT NULL, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + start_at INT NOT NULL, + end_at INT NOT NULL, + pre_chunk_id VARCHAR(36), + next_chunk_id VARCHAR(36), + chunk_type VARCHAR(20) NOT NULL DEFAULT 'text', + parent_chunk_id VARCHAR(36), + image_info LONGTEXT, + video_info LONGTEXT, + relation_chunks LONGTEXT, + indirect_relation_chunks LONGTEXT, + metadata LONGTEXT, + tag_id VARCHAR(36), + status INT NOT NULL DEFAULT 0, + content_hash VARCHAR(64), + flags INT NOT NULL DEFAULT 1, + seq_id INT, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_chunks_tenant_kg ON chunks(tenant_id, knowledge_id); +CREATE INDEX idx_chunks_parent_id ON chunks(parent_chunk_id); +CREATE INDEX idx_chunks_chunk_type ON chunks(chunk_type); +CREATE INDEX idx_chunks_tag ON chunks(tag_id); +CREATE INDEX idx_chunks_content_hash ON chunks(content_hash); +CREATE UNIQUE INDEX idx_chunks_seq_id ON chunks(seq_id); +CREATE INDEX idx_chunks_kb_tenant ON chunks(knowledge_base_id, tenant_id); +CREATE INDEX idx_chunks_knowledge_enabled ON chunks(knowledge_id, is_enabled, deleted_at); + +CREATE TABLE IF NOT EXISTS chunk_revisions ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + knowledge_id VARCHAR(36) NOT NULL, + chunk_id VARCHAR(36) NOT NULL, + revision INT NOT NULL, + content LONGTEXT NOT NULL DEFAULT (''), + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + editor_id VARCHAR(64) NOT NULL DEFAULT '', + edit_source VARCHAR(16) NOT NULL DEFAULT 'user', + edited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE(chunk_id, revision) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_chunk_revisions_tenant_chunk ON chunk_revisions(tenant_id, chunk_id); + +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + avatar VARCHAR(500), + tenant_id INT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + can_access_all_tenants TINYINT(1) NOT NULL DEFAULT 0, + is_system_admin TINYINT(1) NOT NULL DEFAULT 0, + -- Per-user JSON preferences (memory toggle, future UI knobs). + -- SQLite has no JSONB; store as LONGTEXT and let GORM (de)serialise via + -- the driver.Valuer / sql.Scanner methods on types.UserPreferences. + preferences LONGTEXT NOT NULL DEFAULT ('{}'), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_tenant_id ON users(tenant_id); +CREATE INDEX idx_users_is_system_admin ON users(is_system_admin); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + token LONGTEXT NOT NULL, + token_type VARCHAR(50) NOT NULL, + expires_at DATETIME(3) NOT NULL, + is_revoked TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_auth_tokens_user_id ON auth_tokens(user_id); +CREATE INDEX idx_auth_tokens_token ON auth_tokens(token(191)); +CREATE INDEX idx_auth_tokens_token_type ON auth_tokens(token_type); +CREATE INDEX idx_auth_tokens_expires_at ON auth_tokens(expires_at); + +-- tenant_members carries the per-(user, tenant) TenantRole used by the +-- tenant-level RBAC introduced in #1303. SQLite does not support partial +-- indexes the same way Postgres does, so we use a plain unique index on +-- (user_id, tenant_id) — soft-deleted rows are filtered by the GORM scope. +CREATE TABLE IF NOT EXISTS tenant_members ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'contributor', + status VARCHAR(20) NOT NULL DEFAULT 'active', + invited_by VARCHAR(36), + joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE UNIQUE INDEX idx_tenant_members_user_tenant_unique + ON tenant_members(user_id, tenant_id); +CREATE INDEX idx_tenant_members_tenant_role + ON tenant_members(tenant_id, role); +CREATE INDEX idx_tenant_members_user + ON tenant_members(user_id); + +-- audit_logs is the generic per-tenant durability for RBAC events +-- (and future KB / agent / datasource events). Sqlite mirror of the +-- 000044_audit_log migration; same column shape with INT for the +-- BIGSERIAL id and LONGTEXT in place of JSONB for details. +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + actor_user_id VARCHAR(36) NOT NULL DEFAULT '', + actor_role VARCHAR(32) NOT NULL DEFAULT '', + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL DEFAULT '', + target_id VARCHAR(64) NOT NULL DEFAULT '', + target_user_id VARCHAR(36) NOT NULL DEFAULT '', + request_path VARCHAR(512) NOT NULL DEFAULT '', + request_method VARCHAR(16) NOT NULL DEFAULT '', + outcome VARCHAR(16) NOT NULL DEFAULT 'success', + details LONGTEXT NOT NULL DEFAULT ('{}'), + scope_type VARCHAR(32) NOT NULL DEFAULT '', + scope_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_audit_logs_tenant_id_desc + ON audit_logs(tenant_id, id DESC); +CREATE INDEX idx_audit_logs_actor + ON audit_logs(actor_user_id); +CREATE INDEX idx_audit_logs_tenant_action + ON audit_logs(tenant_id, action); +CREATE INDEX idx_audit_logs_created_at + ON audit_logs(created_at); +CREATE INDEX idx_audit_logs_tenant_scope_desc + ON audit_logs(tenant_id, scope_type, scope_id, id DESC); + +-- user_resource_favorites — sqlite mirror of migration 000047. Same +-- composite PK (user_id, tenant_id, resource_type, resource_id) so the +-- GORM model and FirstOrCreate idempotency carry over. +CREATE TABLE IF NOT EXISTS user_resource_favorites ( + user_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + resource_type VARCHAR(16) NOT NULL, + resource_id VARCHAR(64) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, tenant_id, resource_type, resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_user_resource_favorites_user_tenant_type_created_at + ON user_resource_favorites(user_id, tenant_id, resource_type, created_at DESC); +CREATE INDEX idx_user_resource_favorites_tenant_id + ON user_resource_favorites(tenant_id); + +-- user_kb_pins — sqlite mirror of migration 000050. Per-(user, tenant) +-- pinned knowledge bases; replaces the tenant-wide knowledge_bases.is_pinned +-- column for ordering purposes. The legacy column on knowledge_bases is +-- still defined above for back-compat with existing rows but is no longer +-- written by the application. +CREATE TABLE IF NOT EXISTS user_kb_pins ( + tenant_id INT NOT NULL, + user_id VARCHAR(36) NOT NULL, + kb_id VARCHAR(36) NOT NULL, + pinned_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, user_id, kb_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_user_kb_pins_user_tenant_pinned_at + ON user_kb_pins(tenant_id, user_id, pinned_at DESC); + +-- tenant_invitations — sqlite mirror of migration 000048. SQLite supports +-- partial unique indexes too, so the same "one pending per (tenant, +-- invitee)" guard can be applied verbatim. +CREATE TABLE IF NOT EXISTS tenant_invitations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + invitee_user_id VARCHAR(36) NOT NULL, + invited_by VARCHAR(36), + role VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + message VARCHAR(500), + expires_at DATETIME(3) NOT NULL, + responded_at DATETIME(3), + token VARCHAR(64) NOT NULL DEFAULT '', + accepted_count INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_tenant_invitations_unique_pending + ON tenant_invitations(tenant_id, invitee_user_id); +CREATE INDEX idx_tenant_invitations_tenant + ON tenant_invitations(tenant_id); +CREATE INDEX idx_tenant_invitations_invitee + ON tenant_invitations(invitee_user_id); +CREATE INDEX idx_tenant_invitations_token ON tenant_invitations(token); + +CREATE TABLE IF NOT EXISTS knowledge_tags ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + name VARCHAR(128) NOT NULL, + color VARCHAR(32), + sort_order INT NOT NULL DEFAULT 0, + seq_id INT, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_knowledge_tags_kb_name ON knowledge_tags(tenant_id, knowledge_base_id, name); +CREATE INDEX idx_knowledge_tags_kb ON knowledge_tags(tenant_id, knowledge_base_id); +CREATE UNIQUE INDEX idx_knowledge_tags_seq_id ON knowledge_tags(seq_id); + +CREATE TABLE IF NOT EXISTS mcp_services ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description LONGTEXT, + enabled TINYINT(1) DEFAULT 1, + transport_type VARCHAR(50) NOT NULL, + url VARCHAR(512), + headers LONGTEXT, + auth_config LONGTEXT, + advanced_config LONGTEXT, + stdio_config LONGTEXT, + env_vars LONGTEXT, + is_builtin TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_mcp_services_tenant_id ON mcp_services(tenant_id); +CREATE INDEX idx_mcp_services_enabled ON mcp_services(enabled); +CREATE INDEX idx_mcp_services_is_builtin ON mcp_services(is_builtin); +CREATE INDEX idx_mcp_services_deleted_at ON mcp_services(deleted_at); + +CREATE TABLE IF NOT EXISTS mcp_tool_approvals ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + service_id VARCHAR(36) NOT NULL, + tool_name VARCHAR(512) NOT NULL, + require_approval TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_mcp_tool_approvals_tenant_svc_tool ON mcp_tool_approvals(tenant_id, service_id, tool_name); +CREATE INDEX idx_mcp_tool_approvals_service_id ON mcp_tool_approvals(service_id); + +CREATE TABLE IF NOT EXISTS mcp_oauth_clients ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + service_id VARCHAR(36) NOT NULL, + client_id VARCHAR(512) NOT NULL, + client_secret LONGTEXT, + redirect_uri VARCHAR(1024), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_mcp_oauth_clients_tenant_svc ON mcp_oauth_clients(tenant_id, service_id); +CREATE INDEX idx_mcp_oauth_clients_service_id ON mcp_oauth_clients(service_id); + +CREATE TABLE IF NOT EXISTS mcp_oauth_tokens ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + user_id VARCHAR(512) NOT NULL, + principal_type VARCHAR(32) NOT NULL DEFAULT 'web_user', + principal_id VARCHAR(512) NOT NULL DEFAULT '', + service_id VARCHAR(36) NOT NULL, + access_token LONGTEXT, + refresh_token LONGTEXT, + token_type VARCHAR(32), + expires_at DATETIME(3), + refresh_lease_id VARCHAR(36), + refresh_lease_until DATETIME(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_mcp_oauth_tokens_tenant_principal_svc ON mcp_oauth_tokens(tenant_id, principal_type, principal_id, service_id); +CREATE INDEX idx_mcp_oauth_tokens_service_id ON mcp_oauth_tokens(service_id); +CREATE INDEX idx_mcp_oauth_tokens_user_id ON mcp_oauth_tokens(user_id); +CREATE INDEX idx_mcp_oauth_tokens_principal ON mcp_oauth_tokens(principal_type, principal_id); + +CREATE TABLE IF NOT EXISTS custom_agents ( + id VARCHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + description LONGTEXT, + avatar VARCHAR(64), + is_builtin TINYINT(1) NOT NULL DEFAULT 0, + tenant_id INT NOT NULL, + created_by VARCHAR(36), + runnable_by_viewer TINYINT(1) NOT NULL DEFAULT 1, + config LONGTEXT NOT NULL DEFAULT ('{}'), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3), + PRIMARY KEY (id, tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_custom_agents_tenant_id ON custom_agents(tenant_id); +CREATE INDEX idx_custom_agents_is_builtin ON custom_agents(is_builtin); +CREATE INDEX idx_custom_agents_deleted_at ON custom_agents(deleted_at); + +CREATE TABLE IF NOT EXISTS organizations ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description LONGTEXT, + owner_id VARCHAR(36) NOT NULL, + -- Plan 3 (#1303): owning tenant pinned at create time; see migration 000046. + owner_tenant_id INT NOT NULL DEFAULT 0, + invite_code VARCHAR(32), + require_approval TINYINT(1) DEFAULT 0, + invite_code_expires_at DATETIME(3), + invite_code_validity_days SMALLINT NOT NULL DEFAULT 7, + avatar VARCHAR(512) DEFAULT '', + searchable TINYINT(1) NOT NULL DEFAULT 0, + member_limit INT NOT NULL DEFAULT 50, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_organizations_owner_id ON organizations(owner_id); +CREATE INDEX idx_organizations_owner_tenant ON organizations(owner_tenant_id); +CREATE INDEX idx_organizations_deleted_at ON organizations(deleted_at); + +CREATE TABLE IF NOT EXISTS organization_tenant_members ( + id VARCHAR(36) PRIMARY KEY, + organization_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'viewer', + representative_user_id VARCHAR(36) NOT NULL DEFAULT '', + joined_at DATETIME(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_org_tenant_members_unique ON organization_tenant_members(organization_id, tenant_id); +CREATE INDEX idx_org_tenant_members_by_tenant ON organization_tenant_members(tenant_id); +CREATE INDEX idx_org_tenant_members_role ON organization_tenant_members(organization_id, role); + +CREATE TABLE IF NOT EXISTS kb_shares ( + id VARCHAR(36) PRIMARY KEY, + knowledge_base_id VARCHAR(36) NOT NULL, + organization_id VARCHAR(36) NOT NULL, + shared_by_user_id VARCHAR(36) NOT NULL, + source_tenant_id INT NOT NULL, + permission VARCHAR(32) NOT NULL DEFAULT 'viewer', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_kb_shares_kb_id ON kb_shares(knowledge_base_id); +CREATE INDEX idx_kb_shares_org_id ON kb_shares(organization_id); +CREATE INDEX idx_kb_shares_source_tenant ON kb_shares(source_tenant_id); +CREATE INDEX idx_kb_shares_deleted_at ON kb_shares(deleted_at); + +CREATE TABLE IF NOT EXISTS organization_join_requests ( + id VARCHAR(36) PRIMARY KEY, + organization_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + requested_role VARCHAR(32) NOT NULL DEFAULT 'viewer', + request_type VARCHAR(32) NOT NULL DEFAULT 'join', + prev_role VARCHAR(32), + message LONGTEXT, + reviewed_by VARCHAR(36), + reviewed_at DATETIME(3), + review_message LONGTEXT, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_org_join_requests_org_id ON organization_join_requests(organization_id); +CREATE INDEX idx_org_join_requests_user_id ON organization_join_requests(user_id); +CREATE INDEX idx_org_join_requests_status ON organization_join_requests(status); +-- Plan 3 (#1303): at most one pending request per (org, tenant, type). +-- Approved/rejected rows are not constrained so the audit trail stays. +CREATE INDEX uq_org_join_requests_pending_per_tenant + ON organization_join_requests(organization_id, tenant_id, request_type); + +CREATE TABLE IF NOT EXISTS agent_shares ( + id VARCHAR(36) PRIMARY KEY, + agent_id VARCHAR(36) NOT NULL, + organization_id VARCHAR(36) NOT NULL, + shared_by_user_id VARCHAR(36) NOT NULL, + source_tenant_id INT NOT NULL, + permission VARCHAR(32) NOT NULL DEFAULT 'viewer', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_agent_shares_agent_id ON agent_shares(agent_id); +CREATE INDEX idx_agent_shares_org_id ON agent_shares(organization_id); +CREATE INDEX idx_agent_shares_source_tenant ON agent_shares(source_tenant_id); +CREATE INDEX idx_agent_shares_deleted_at ON agent_shares(deleted_at); + +CREATE TABLE IF NOT EXISTS tenant_disabled_shared_agents ( + tenant_id BIGINT NOT NULL, + agent_id VARCHAR(36) NOT NULL, + source_tenant_id BIGINT NOT NULL, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, agent_id, source_tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_tenant_disabled_shared_agents_tenant_id ON tenant_disabled_shared_agents(tenant_id); + +CREATE TABLE IF NOT EXISTS im_channel_sessions ( + id VARCHAR(36) PRIMARY KEY, + platform VARCHAR(20) NOT NULL, + user_id VARCHAR(128) NOT NULL, + chat_id VARCHAR(128) NOT NULL DEFAULT '', + session_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + agent_id VARCHAR(36) DEFAULT '', + im_channel_id VARCHAR(36) DEFAULT '', + thread_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'active', + metadata LONGTEXT DEFAULT ('{}'), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_channel_lookup + ON im_channel_sessions (platform, user_id, chat_id, tenant_id); +CREATE INDEX idx_channel_thread_lookup + ON im_channel_sessions (platform, chat_id, thread_id, tenant_id); +CREATE INDEX idx_im_channel_tenant ON im_channel_sessions (tenant_id); +CREATE INDEX idx_im_channel_session ON im_channel_sessions (session_id); +CREATE INDEX idx_im_channel_sessions_channel ON im_channel_sessions (im_channel_id); + +CREATE TABLE IF NOT EXISTS im_channels ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + agent_id VARCHAR(36) NOT NULL, + platform VARCHAR(20) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + enabled INT NOT NULL DEFAULT 1, + mode VARCHAR(20) NOT NULL DEFAULT 'websocket', + output_mode VARCHAR(20) NOT NULL DEFAULT 'stream', + credentials LONGTEXT NOT NULL DEFAULT ('{}'), + knowledge_base_id VARCHAR(36) DEFAULT '', + bot_identity VARCHAR(255) NOT NULL DEFAULT '', + session_mode VARCHAR(20) NOT NULL DEFAULT 'user', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_im_channels_tenant ON im_channels (tenant_id); +CREATE INDEX idx_im_channels_agent ON im_channels (agent_id); +CREATE INDEX idx_im_channels_bot_identity + ON im_channels (bot_identity); + +CREATE TABLE IF NOT EXISTS embed_channels ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + agent_id VARCHAR(36) NOT NULL DEFAULT 'builtin-quick-answer', + name VARCHAR(255) NOT NULL DEFAULT '', + enabled INT NOT NULL DEFAULT 1, + publish_token VARCHAR(64) NOT NULL DEFAULT '', + allowed_origins LONGTEXT NOT NULL DEFAULT ('[]'), + welcome_message LONGTEXT NOT NULL DEFAULT (''), + rate_limit_per_minute INT NOT NULL DEFAULT 30, + rate_limit_per_day INT NOT NULL DEFAULT 10000, + primary_color VARCHAR(32) NOT NULL DEFAULT '', + page_title VARCHAR(255) NOT NULL DEFAULT '', + header_title_mode VARCHAR(32) NOT NULL DEFAULT 'channel', + show_suggested_questions INT NOT NULL DEFAULT 1, + widget_position VARCHAR(32) NOT NULL DEFAULT 'bottom-right', + allow_web_search INT NOT NULL DEFAULT 0, + allow_file_upload INT NOT NULL DEFAULT 0, + default_locale VARCHAR(16) NOT NULL DEFAULT '', + webhook_url VARCHAR(512) NOT NULL DEFAULT '', + webhook_secret VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_embed_channels_tenant ON embed_channels (tenant_id); +CREATE INDEX idx_embed_channels_agent ON embed_channels (agent_id); +CREATE INDEX idx_embed_channels_publish_token + ON embed_channels (publish_token); + +CREATE TABLE IF NOT EXISTS data_sources ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + config LONGTEXT, + sync_schedule VARCHAR(100), + sync_mode VARCHAR(20) DEFAULT 'incremental', + status VARCHAR(32) DEFAULT 'active', + conflict_strategy VARCHAR(32) DEFAULT 'overwrite', + sync_deletions INT DEFAULT 1, + last_sync_at DATETIME(3) NULL, + last_sync_cursor LONGTEXT, + last_sync_result LONGTEXT, + error_message LONGTEXT, + sync_log_retention_days INT DEFAULT 30, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_data_sources_tenant_id ON data_sources (tenant_id); +CREATE INDEX idx_data_sources_knowledge_base_id ON data_sources (knowledge_base_id); +CREATE INDEX idx_data_sources_type ON data_sources (type); +CREATE INDEX idx_data_sources_status ON data_sources (status); +CREATE INDEX idx_data_sources_deleted_at ON data_sources (deleted_at); + +CREATE TABLE IF NOT EXISTS sync_logs ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + data_source_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + status VARCHAR(32) NOT NULL, + started_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + finished_at DATETIME(3) NULL, + items_total INT DEFAULT 0, + items_created INT DEFAULT 0, + items_updated INT DEFAULT 0, + items_deleted INT DEFAULT 0, + items_skipped INT DEFAULT 0, + items_failed INT DEFAULT 0, + error_message LONGTEXT, + result LONGTEXT, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_sync_logs_data_source_id ON sync_logs (data_source_id); +CREATE INDEX idx_sync_logs_tenant_id ON sync_logs (tenant_id); +CREATE INDEX idx_sync_logs_status ON sync_logs (status); +CREATE INDEX idx_sync_logs_started_at ON sync_logs (started_at); + +CREATE TABLE IF NOT EXISTS web_search_providers ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + tenant_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + provider VARCHAR(50) NOT NULL, + description LONGTEXT, + parameters LONGTEXT, + is_default INT DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_web_search_providers_tenant_id ON web_search_providers (tenant_id); +CREATE INDEX idx_web_search_providers_provider ON web_search_providers (provider); +CREATE INDEX idx_web_search_providers_deleted_at ON web_search_providers (deleted_at); + +CREATE TABLE IF NOT EXISTS vector_stores ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + engine_type VARCHAR(50) NOT NULL, + connection_config LONGTEXT NOT NULL DEFAULT ('{}'), + index_config LONGTEXT NOT NULL DEFAULT ('{}'), + tenant_id INT NOT NULL, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_vector_stores_name_tenant + ON vector_stores(name, tenant_id); +CREATE INDEX idx_vector_stores_tenant_id ON vector_stores(tenant_id); +CREATE INDEX idx_vector_stores_engine_type ON vector_stores(engine_type); +CREATE INDEX idx_vector_stores_deleted_at ON vector_stores(deleted_at); + +CREATE TABLE IF NOT EXISTS storage_backends ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + provider VARCHAR(32) NOT NULL, + config LONGTEXT NOT NULL DEFAULT ('{}'), + source VARCHAR(16) NOT NULL DEFAULT 'user', + status VARCHAR(16) NOT NULL DEFAULT 'active', + legacy_alias TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_storage_backends_name_tenant + ON storage_backends(tenant_id, name); +CREATE INDEX idx_storage_backends_legacy_alias + ON storage_backends(tenant_id, provider, legacy_alias); +CREATE INDEX idx_storage_backends_tenant ON storage_backends(tenant_id); + +CREATE TABLE IF NOT EXISTS resources ( + id VARCHAR(36) PRIMARY KEY, + handle VARCHAR(22) NOT NULL UNIQUE, + tenant_id INT NOT NULL, + storage_backend_id VARCHAR(36), + provider VARCHAR(32) NOT NULL, + physical_path LONGTEXT NOT NULL, + location_hash VARCHAR(64) NOT NULL, + kind VARCHAR(32) NOT NULL DEFAULT 'file', + mime_type VARCHAR(255) NOT NULL DEFAULT '', + original_name VARCHAR(1024) NOT NULL DEFAULT '', + size INT NOT NULL DEFAULT 0, + content_hash VARCHAR(64) NOT NULL DEFAULT '', + lifecycle VARCHAR(16) NOT NULL DEFAULT 'persistent', + expires_at DATETIME(3), + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_resources_tenant_location + ON resources(tenant_id, location_hash); +CREATE INDEX idx_resources_tenant ON resources(tenant_id); +CREATE INDEX idx_resources_backend ON resources(storage_backend_id); + +CREATE TABLE IF NOT EXISTS resource_bindings ( + id VARCHAR(36) PRIMARY KEY, + resource_id VARCHAR(36) NOT NULL, + tenant_id INT NOT NULL, + owner_type VARCHAR(32) NOT NULL, + owner_id VARCHAR(64) NOT NULL, + relation VARCHAR(32) NOT NULL DEFAULT 'attachment', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE UNIQUE INDEX idx_resource_bindings_unique + ON resource_bindings(resource_id, owner_type, owner_id, relation); +CREATE INDEX idx_resource_bindings_owner + ON resource_bindings(tenant_id, owner_type, owner_id); + +CREATE TABLE IF NOT EXISTS resource_access_grants ( + id VARCHAR(36) PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL UNIQUE, + resource_id VARCHAR(36) NOT NULL, + access_scope VARCHAR(16) NOT NULL DEFAULT 'read', + expires_at DATETIME(3) NOT NULL, + revoked_at DATETIME(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_resource_access_grants_resource + ON resource_access_grants(resource_id); +CREATE INDEX idx_resource_access_grants_expires + ON resource_access_grants(expires_at); + +CREATE TABLE IF NOT EXISTS tenant_api_keys ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT, + scope_type VARCHAR(16) NOT NULL DEFAULT 'tenant' + CHECK (scope_type IN ('tenant', 'platform')), + name VARCHAR(128) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + api_key LONGTEXT NOT NULL DEFAULT (''), + full_access TINYINT(1) NOT NULL DEFAULT 0, + knowledge_base_ids LONGTEXT NOT NULL DEFAULT ('[]'), + capabilities LONGTEXT NOT NULL DEFAULT ('[]'), + last_used_at DATETIME(3), + expires_at DATETIME(3), + revoked_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CHECK ( + (scope_type = 'tenant' AND tenant_id IS NOT NULL) + OR (scope_type = 'platform' AND tenant_id IS NULL AND full_access = 0) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_tenant_api_keys_tenant ON tenant_api_keys(tenant_id); +CREATE INDEX idx_tenant_api_keys_revoked_at ON tenant_api_keys(revoked_at); +CREATE INDEX idx_tenant_api_keys_scope_type ON tenant_api_keys(scope_type); + +CREATE TABLE IF NOT EXISTS temporary_documents ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + session_id VARCHAR(36) NOT NULL, + resource_ref LONGTEXT NOT NULL, + file_name VARCHAR(1024) NOT NULL, + file_type VARCHAR(32) NOT NULL, + mime_type VARCHAR(255) NOT NULL DEFAULT '', + file_size INT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'uploaded', + content LONGTEXT NOT NULL DEFAULT (''), + chunks LONGTEXT NOT NULL DEFAULT ('[]'), + image_refs LONGTEXT NOT NULL DEFAULT ('[]'), + metadata LONGTEXT NOT NULL DEFAULT ('{}'), + processing_options LONGTEXT NOT NULL DEFAULT ('{}'), + token_count INT NOT NULL DEFAULT 0, + chunk_count INT NOT NULL DEFAULT 0, + error_message LONGTEXT NOT NULL DEFAULT (''), + expires_at DATETIME(3) NOT NULL, + started_at DATETIME(3), + ready_at DATETIME(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_temporary_documents_scope ON temporary_documents(tenant_id, session_id); +CREATE INDEX idx_temporary_documents_status ON temporary_documents(status); +CREATE INDEX idx_temporary_documents_expires ON temporary_documents(expires_at); + +-- --------------------------------------------------------------------------- +-- Wiki (consolidated from Postgres migrations 000037, 000040, 000061, 000075) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS wiki_pages ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + slug VARCHAR(255) NOT NULL, + title VARCHAR(512) NOT NULL DEFAULT '', + page_type VARCHAR(32) NOT NULL DEFAULT 'summary', + status VARCHAR(32) NOT NULL DEFAULT 'published', + content LONGTEXT NOT NULL DEFAULT (''), + summary LONGTEXT NOT NULL DEFAULT (''), + parent_slug VARCHAR(255) NOT NULL DEFAULT '', + folder_id VARCHAR(36) NOT NULL DEFAULT '', + category_path LONGTEXT DEFAULT ('[]'), + wiki_path VARCHAR(1024) NOT NULL DEFAULT '', + depth INT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + source_refs LONGTEXT DEFAULT ('[]'), + chunk_refs LONGTEXT DEFAULT ('[]'), + in_links LONGTEXT DEFAULT ('[]'), + out_links LONGTEXT DEFAULT ('[]'), + page_metadata LONGTEXT DEFAULT ('{}'), + aliases LONGTEXT DEFAULT ('[]'), + version INT NOT NULL DEFAULT 1, + last_edit_source VARCHAR(16) NOT NULL DEFAULT '', + last_editor_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_wiki_pages_kb_slug + ON wiki_pages (knowledge_base_id, slug); + +CREATE INDEX idx_wiki_pages_kb_id + ON wiki_pages (knowledge_base_id); + +CREATE INDEX idx_wiki_pages_page_type + ON wiki_pages (knowledge_base_id, page_type); + +CREATE INDEX idx_wiki_pages_parent_slug + ON wiki_pages (knowledge_base_id, parent_slug); + +CREATE INDEX idx_wiki_pages_tree + ON wiki_pages (knowledge_base_id, page_type, wiki_path(191), sort_order, title(191)); + +CREATE INDEX idx_wiki_pages_folder + ON wiki_pages (knowledge_base_id, folder_id); + +CREATE INDEX idx_wiki_pages_tenant_id + ON wiki_pages (tenant_id); + +CREATE INDEX idx_wiki_pages_deleted_at + ON wiki_pages (deleted_at); + +CREATE TABLE IF NOT EXISTS wiki_folders ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL DEFAULT 0, + knowledge_base_id VARCHAR(36) NOT NULL, + parent_id VARCHAR(36) NOT NULL DEFAULT '', + name VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL DEFAULT '', + depth INT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_wiki_folders_parent_name + ON wiki_folders (knowledge_base_id, parent_id, name); + +CREATE INDEX idx_wiki_folders_parent + ON wiki_folders (knowledge_base_id, parent_id); + +CREATE INDEX idx_wiki_folders_deleted_at + ON wiki_folders (deleted_at); + +CREATE TABLE IF NOT EXISTS wiki_page_issues ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + slug VARCHAR(255) NOT NULL, + issue_type VARCHAR(50) NOT NULL, + description LONGTEXT NOT NULL, + suspected_knowledge_ids LONGTEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reported_by VARCHAR(100) NOT NULL, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_wiki_page_issues_tenant_id + ON wiki_page_issues(tenant_id); + +CREATE INDEX idx_wiki_page_issues_knowledge_base_id + ON wiki_page_issues(knowledge_base_id); + +CREATE INDEX idx_wiki_page_issues_slug + ON wiki_page_issues(slug); + +CREATE INDEX idx_wiki_page_issues_status + ON wiki_page_issues(status); + +CREATE TABLE IF NOT EXISTS wiki_page_revisions ( + id VARCHAR(36) PRIMARY KEY, + tenant_id INT NOT NULL, + knowledge_base_id VARCHAR(36) NOT NULL, + page_id VARCHAR(36) NOT NULL, + slug VARCHAR(255) NOT NULL, + version INT NOT NULL, + title VARCHAR(512) NOT NULL DEFAULT '', + page_type VARCHAR(32) NOT NULL DEFAULT 'summary', + status VARCHAR(32) NOT NULL DEFAULT 'published', + content LONGTEXT NOT NULL DEFAULT (''), + summary LONGTEXT NOT NULL DEFAULT (''), + aliases LONGTEXT DEFAULT ('[]'), + edit_source VARCHAR(16) NOT NULL DEFAULT '', + editor_id VARCHAR(64) NOT NULL DEFAULT '', + edited_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE UNIQUE INDEX idx_wiki_page_revisions_page_version + ON wiki_page_revisions (page_id, version); + +CREATE INDEX idx_wiki_page_revisions_kb_slug + ON wiki_page_revisions (knowledge_base_id, slug); + + +-- Additional tables/relations added after the SQLite consolidated baseline. +CREATE TABLE IF NOT EXISTS task_pending_ops ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + task_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + scope_id VARCHAR(64) NOT NULL, + op VARCHAR(32) NOT NULL, + dedup_key VARCHAR(128) NOT NULL DEFAULT '', + payload LONGTEXT NOT NULL DEFAULT ('{}'), + fail_count INT NOT NULL DEFAULT 0, + enqueued_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + claimed_at DATETIME(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_task_pending_ops_scope ON task_pending_ops(task_type, scope, scope_id, id); +CREATE INDEX idx_task_pending_ops_tenant ON task_pending_ops(tenant_id); + +CREATE TABLE IF NOT EXISTS task_dead_letters ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + task_type VARCHAR(64) NOT NULL, + scope VARCHAR(32) NOT NULL, + scope_id VARCHAR(64) NOT NULL, + related_id VARCHAR(64) NOT NULL DEFAULT '', + payload LONGTEXT NOT NULL, + last_error LONGTEXT NOT NULL, + fail_count INT NOT NULL, + failed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_task_dead_letters_scope ON task_dead_letters(scope, scope_id, failed_at DESC); +CREATE INDEX idx_task_dead_letters_tenant ON task_dead_letters(tenant_id, failed_at DESC); +CREATE INDEX idx_task_dead_letters_task_type ON task_dead_letters(task_type, failed_at DESC); + +CREATE TABLE IF NOT EXISTS system_settings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + `key` VARCHAR(128) NOT NULL UNIQUE, + value LONGTEXT NOT NULL, + value_type VARCHAR(16) NOT NULL, + category VARCHAR(32) NOT NULL, + description LONGTEXT NOT NULL DEFAULT (''), + is_secret TINYINT(1) NOT NULL DEFAULT 0, + requires_restart TINYINT(1) NOT NULL DEFAULT 0, + last_modified_by VARCHAR(36) NOT NULL DEFAULT '', + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_system_settings_category ON system_settings(category); + +CREATE TABLE IF NOT EXISTS knowledge_processing_spans ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + knowledge_id VARCHAR(36) NOT NULL, + attempt INT NOT NULL DEFAULT 1, + span_id VARCHAR(64) NOT NULL, + parent_span_id VARCHAR(64) NOT NULL DEFAULT '', + name VARCHAR(255) NOT NULL, + kind VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + input LONGTEXT, + output LONGTEXT, + metadata LONGTEXT, + error_code VARCHAR(64) NOT NULL DEFAULT '', + error_message LONGTEXT NOT NULL DEFAULT (''), + error_detail LONGTEXT NOT NULL DEFAULT (''), + started_at DATETIME(3), + finished_at DATETIME(3), + duration_ms BIGINT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY idx_kpspan_unique (knowledge_id, attempt, span_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_kpspan_knowledge_attempt ON knowledge_processing_spans(knowledge_id, attempt); +CREATE INDEX idx_kpspan_status_started ON knowledge_processing_spans(status, started_at); +CREATE INDEX idx_kpspan_parent ON knowledge_processing_spans(parent_span_id); + +CREATE TABLE IF NOT EXISTS knowledge_tag_relations ( + knowledge_id VARCHAR(36) NOT NULL, + tag_id VARCHAR(36) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (knowledge_id, tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX idx_ktr_knowledge ON knowledge_tag_relations(knowledge_id); +CREATE INDEX idx_ktr_tag ON knowledge_tag_relations(tag_id); diff --git a/scripts/migrate.sh b/scripts/migrate.sh index f11514f85c..e64b58dcd3 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -14,49 +14,70 @@ if [ -f "$PROJECT_ROOT/.env" ]; then fi # Database connection details (can be overridden by environment variables) +DB_DRIVER=${DB_DRIVER:-postgres} DB_HOST=${DB_HOST:-localhost} -DB_PORT=${DB_PORT:-5432} -DB_USER=${DB_USER:-postgres} +if [ "$DB_DRIVER" = "mysql" ]; then + DB_PORT=${DB_PORT:-3306} + DB_USER=${DB_USER:-weknora} +else + DB_PORT=${DB_PORT:-5432} + DB_USER=${DB_USER:-postgres} +fi DB_PASSWORD=${DB_PASSWORD:-postgres} DB_NAME=${DB_NAME:-WeKnora} -# Use versioned migrations directory -MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations/versioned}" +# Use dialect-specific migrations directory +case "$DB_DRIVER" in + mysql) MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations/mysql}" ;; + sqlite) MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations/sqlite}" ;; + *) MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations/versioned}" ;; +esac # Check if migrate tool is installed if ! command -v migrate &> /dev/null; then echo "Error: migrate tool is not installed" - echo "Install it with: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest" + echo "Install it with: go install -tags 'postgres mysql sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest" exit 1 fi # Construct the database URL -# If DB_URL is already set in .env, use it but ensure sslmode=disable is set -# Otherwise, construct it from individual components +# If DB_URL is already set in .env, use it (PostgreSQL URLs are normalized to +# sslmode=disable for local dev). Otherwise, construct it from individual components. if [ -n "$DB_URL" ]; then - # If DB_URL already exists, ensure sslmode=disable is set (unless sslmode is already specified) - if [[ "$DB_URL" != *"sslmode="* ]]; then - # Add sslmode=disable if not present - if [[ "$DB_URL" == *"?"* ]]; then - DB_URL="${DB_URL}&sslmode=disable" - else - DB_URL="${DB_URL}?sslmode=disable" + if [ "$DB_DRIVER" = "postgres" ]; then + if [[ "$DB_URL" != *"sslmode="* ]]; then + if [[ "$DB_URL" == *"?"* ]]; then + DB_URL="${DB_URL}&sslmode=disable" + else + DB_URL="${DB_URL}?sslmode=disable" + fi + elif [[ "$DB_URL" == *"sslmode=require"* ]] || [[ "$DB_URL" == *"sslmode=prefer"* ]]; then + DB_URL="${DB_URL//sslmode=require/sslmode=disable}" + DB_URL="${DB_URL//sslmode=prefer/sslmode=disable}" fi - elif [[ "$DB_URL" == *"sslmode=require"* ]] || [[ "$DB_URL" == *"sslmode=prefer"* ]]; then - # Replace sslmode=require/prefer with sslmode=disable for local dev - DB_URL="${DB_URL//sslmode=require/sslmode=disable}" - DB_URL="${DB_URL//sslmode=prefer/sslmode=disable}" fi else - # Use Python to properly URL encode password if it contains special characters - # This handles special characters in passwords correctly + # Use Python to properly URL encode credentials if they contain special characters. if command -v python3 &> /dev/null; then - ENCODED_PASSWORD=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$DB_PASSWORD', safe=''))") + ENCODED_USER=$(DB_USER="$DB_USER" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["DB_USER"], safe=""))') + ENCODED_PASSWORD=$(DB_PASSWORD="$DB_PASSWORD" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["DB_PASSWORD"], safe=""))') + ENCODED_DB_NAME=$(DB_NAME="$DB_NAME" python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["DB_NAME"], safe=""))') else - # Fallback: try to use printf for basic encoding (may not work for all special chars) + ENCODED_USER="$DB_USER" ENCODED_PASSWORD="$DB_PASSWORD" + ENCODED_DB_NAME="$DB_NAME" fi - DB_URL="postgres://${DB_USER}:${ENCODED_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" + case "$DB_DRIVER" in + mysql) + DB_URL="mysql://${ENCODED_USER}:${ENCODED_PASSWORD}@tcp(${DB_HOST}:${DB_PORT})/${ENCODED_DB_NAME}?multiStatements=true&parseTime=true" + ;; + sqlite) + DB_URL="sqlite3://${DB_PATH:-./data/weknora.db}" + ;; + *) + DB_URL="postgres://${ENCODED_USER}:${ENCODED_PASSWORD}@${DB_HOST}:${DB_PORT}/${ENCODED_DB_NAME}?sslmode=disable" + ;; + esac fi # Execute migration based on command @@ -70,11 +91,11 @@ case "$1" in echo "DB_PORT: ${DB_PORT}" echo "DB_NAME: ${DB_NAME}" echo "MIGRATIONS_DIR: ${MIGRATIONS_DIR}" - migrate -path ${MIGRATIONS_DIR} -database ${DB_URL} up + migrate -path "${MIGRATIONS_DIR}" -database "${DB_URL}" up ;; down) echo "Running migrations down..." - migrate -path ${MIGRATIONS_DIR} -database ${DB_URL} down + migrate -path "${MIGRATIONS_DIR}" -database "${DB_URL}" down ;; create) if [ -z "$2" ]; then @@ -83,14 +104,14 @@ case "$1" in exit 1 fi echo "Creating migration files for $2..." - migrate create -ext sql -dir ${MIGRATIONS_DIR} -seq $2 + migrate create -ext sql -dir "${MIGRATIONS_DIR}" -seq "$2" echo "Created:" echo " - ${MIGRATIONS_DIR}/$(ls -t ${MIGRATIONS_DIR} | head -1)" echo " - ${MIGRATIONS_DIR}/$(ls -t ${MIGRATIONS_DIR} | head -2 | tail -1)" ;; version) echo "Checking current migration version..." - migrate -path ${MIGRATIONS_DIR} -database ${DB_URL} version + migrate -path "${MIGRATIONS_DIR}" -database "${DB_URL}" version ;; force) if [ -z "$2" ]; then @@ -111,7 +132,7 @@ case "$1" in exit 1 fi echo "Migrating to version $2..." - migrate -path ${MIGRATIONS_DIR} -database ${DB_URL} goto $2 + migrate -path "${MIGRATIONS_DIR}" -database "${DB_URL}" goto "$2" ;; *) echo "Usage: $0 {up|down|create |version|force |goto }"