WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "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"
|
||||
)
|
||||
|
||||
var (
|
||||
migrationStateMu sync.RWMutex
|
||||
currentMigrationVersion uint
|
||||
currentMigrationDirty bool
|
||||
migrationVersionSet bool
|
||||
currentMigrationError string
|
||||
)
|
||||
|
||||
// CachedMigrationVersion returns the migration version captured at startup.
|
||||
// Returns (version, dirty, ok). ok is false if the version was never captured.
|
||||
//
|
||||
// Note: when migrations fail mid-way, the cache may still be populated via a
|
||||
// best-effort m.Version() call inside RunMigrationsWithOptions so the system
|
||||
// info endpoint can surface the partial state. Check CachedMigrationError() to
|
||||
// distinguish a clean version reading from a recorded-after-failure one.
|
||||
func CachedMigrationVersion() (uint, bool, bool) {
|
||||
migrationStateMu.RLock()
|
||||
defer migrationStateMu.RUnlock()
|
||||
return currentMigrationVersion, currentMigrationDirty, migrationVersionSet
|
||||
}
|
||||
|
||||
// CachedMigrationError returns the error message captured when the most recent
|
||||
// migration attempt failed at startup. Empty string means migrations either
|
||||
// succeeded or were never run.
|
||||
func CachedMigrationError() string {
|
||||
migrationStateMu.RLock()
|
||||
defer migrationStateMu.RUnlock()
|
||||
return currentMigrationError
|
||||
}
|
||||
|
||||
// setMigrationState records the latest known migration state. Unlike the old
|
||||
// sync.Once-based setter, this is intentionally idempotent-overwrite so the
|
||||
// failure path (which runs after Up() errored) can replace the pre-migration
|
||||
// snapshot taken from the initial m.Version() call.
|
||||
func setMigrationState(version uint, dirty bool, errMsg string, versionKnown bool) {
|
||||
migrationStateMu.Lock()
|
||||
defer migrationStateMu.Unlock()
|
||||
if versionKnown {
|
||||
currentMigrationVersion = version
|
||||
currentMigrationDirty = dirty
|
||||
migrationVersionSet = true
|
||||
}
|
||||
currentMigrationError = errMsg
|
||||
}
|
||||
|
||||
// captureMigrationFailure best-effort queries m for the current version so the
|
||||
// system info endpoint can show "N (failed)" instead of vanishing the row, and
|
||||
// stores the human-readable error message. Always returns the original error.
|
||||
func captureMigrationFailure(m *migrate.Migrate, err error) error {
|
||||
versionKnown := false
|
||||
var ver uint
|
||||
var dirty bool
|
||||
if m != nil {
|
||||
v, d, vErr := m.Version()
|
||||
if vErr == nil {
|
||||
versionKnown = true
|
||||
ver, dirty = v, d
|
||||
}
|
||||
}
|
||||
setMigrationState(ver, dirty, err.Error(), versionKnown)
|
||||
return err
|
||||
}
|
||||
|
||||
// RunMigrations executes all pending database migrations
|
||||
// This should be called during application startup
|
||||
func RunMigrations(dsn string) error {
|
||||
return RunMigrationsWithOptions(dsn, MigrationOptions{AutoRecoverDirty: false})
|
||||
}
|
||||
|
||||
// MigrationOptions configures migration behavior
|
||||
type MigrationOptions struct {
|
||||
// AutoRecoverDirty when true, automatically attempts to recover from dirty state
|
||||
// by forcing to the previous version and retrying the migration
|
||||
AutoRecoverDirty bool
|
||||
|
||||
// SQLiteDBPath is the raw filesystem path to the SQLite database file.
|
||||
// When set, the migrator opens the DB directly via sql.Open instead of
|
||||
// parsing a URL-based DSN, which avoids breakage when the path contains
|
||||
// spaces (e.g. macOS "Application Support").
|
||||
SQLiteDBPath string
|
||||
}
|
||||
|
||||
// RunMigrationsWithOptions executes all pending database migrations with custom options
|
||||
func RunMigrationsWithOptions(dsn string, opts MigrationOptions) error {
|
||||
ctx := context.Background()
|
||||
|
||||
logger.Infof(ctx, "Starting database migration...")
|
||||
|
||||
migrationsPath := "file://migrations/versioned"
|
||||
if strings.HasPrefix(dsn, "sqlite3://") {
|
||||
migrationsPath = "file://migrations/sqlite"
|
||||
}
|
||||
|
||||
var m *migrate.Migrate
|
||||
if opts.SQLiteDBPath != "" {
|
||||
sqlDB, err := sql.Open("sqlite3", opts.SQLiteDBPath)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to open sqlite db for migration: %v", err)
|
||||
wrapped := fmt.Errorf("failed to open sqlite db for migration: %w", err)
|
||||
setMigrationState(0, false, wrapped.Error(), false)
|
||||
return wrapped
|
||||
}
|
||||
driver, err := sqlite3migrate.WithInstance(sqlDB, &sqlite3migrate.Config{})
|
||||
if err != nil {
|
||||
sqlDB.Close()
|
||||
logger.Errorf(ctx, "Failed to create sqlite3 migrate driver: %v", err)
|
||||
wrapped := fmt.Errorf("failed to create sqlite3 migrate driver: %w", err)
|
||||
setMigrationState(0, false, wrapped.Error(), false)
|
||||
return wrapped
|
||||
}
|
||||
m, err = migrate.NewWithDatabaseInstance(migrationsPath, "sqlite3", driver)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to create migrate instance: %v", err)
|
||||
wrapped := fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
setMigrationState(0, false, wrapped.Error(), false)
|
||||
return wrapped
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
m, err = migrate.New(migrationsPath, dsn)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to create migrate instance: %v", err)
|
||||
wrapped := fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
setMigrationState(0, false, wrapped.Error(), false)
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
// Check current version and dirty state before migration
|
||||
oldVersion, oldDirty, versionErr := m.Version()
|
||||
if versionErr != nil && versionErr != migrate.ErrNilVersion {
|
||||
logger.Errorf(ctx, "Failed to get migration version: %v", versionErr)
|
||||
return captureMigrationFailure(m, fmt.Errorf("failed to get migration version: %w", versionErr))
|
||||
}
|
||||
|
||||
if versionErr == migrate.ErrNilVersion {
|
||||
logger.Infof(ctx, "Database has no migration history, will start from version 0")
|
||||
} else {
|
||||
logger.Infof(ctx, "Current migration version: %d, dirty: %v", oldVersion, oldDirty)
|
||||
}
|
||||
|
||||
// If database is in dirty state, try to recover or return error
|
||||
if oldDirty {
|
||||
logger.Warnf(ctx, "Database is in dirty state at version %d", oldVersion)
|
||||
if opts.AutoRecoverDirty {
|
||||
logger.Infof(ctx, "AutoRecoverDirty is enabled, attempting recovery...")
|
||||
if err := recoverFromDirtyState(ctx, m, oldVersion); err != nil {
|
||||
return captureMigrationFailure(m, err)
|
||||
}
|
||||
// Update oldVersion after recovery
|
||||
oldVersion, _, _ = m.Version()
|
||||
} else {
|
||||
// Calculate the version to force to (usually the previous version)
|
||||
forceVersion := int(oldVersion) - 1
|
||||
if oldVersion == 0 || forceVersion < 0 {
|
||||
forceVersion = 0
|
||||
}
|
||||
return captureMigrationFailure(m, fmt.Errorf(
|
||||
"database is in dirty state at version %d. This usually means a migration failed partway through. "+
|
||||
"To fix this:\n"+
|
||||
"1. Check if the migration partially applied changes and manually fix if needed\n"+
|
||||
"2. Use the force command to set the version to the last successful migration (usually %d):\n"+
|
||||
" ./scripts/migrate.sh force %d\n"+
|
||||
" Or if using make: make migrate-force version=%d\n"+
|
||||
"3. After fixing, restart the application to retry the migration\n"+
|
||||
"Or enable AutoRecoverDirty option to automatically retry",
|
||||
oldVersion,
|
||||
forceVersion,
|
||||
forceVersion,
|
||||
forceVersion,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Run all pending migrations
|
||||
logger.Infof(ctx, "Running pending migrations...")
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
logger.Errorf(ctx, "Migration failed: %v", err)
|
||||
// Check if error is due to dirty state (in case it became dirty during migration)
|
||||
currentVersion, currentDirty, versionCheckErr := m.Version()
|
||||
if versionCheckErr == nil && currentDirty {
|
||||
logger.Warnf(ctx, "Migration caused dirty state at version %d", currentVersion)
|
||||
if opts.AutoRecoverDirty {
|
||||
logger.Infof(ctx, "Attempting to recover from dirty state...")
|
||||
// Try to recover and retry
|
||||
if recoverErr := recoverFromDirtyState(ctx, m, currentVersion); recoverErr != nil {
|
||||
return captureMigrationFailure(m, recoverErr)
|
||||
}
|
||||
// Retry migration after recovery
|
||||
logger.Infof(ctx, "Retrying migration after recovery...")
|
||||
if retryErr := m.Up(); retryErr != nil && retryErr != migrate.ErrNoChange {
|
||||
logger.Errorf(ctx, "Migration failed after recovery attempt: %v", retryErr)
|
||||
return captureMigrationFailure(m, fmt.Errorf("migration failed after recovery attempt: %w", retryErr))
|
||||
}
|
||||
} else {
|
||||
// Calculate the version to force to (usually the previous version)
|
||||
forceVersion := currentVersion - 1
|
||||
if currentVersion == 0 {
|
||||
forceVersion = 0
|
||||
}
|
||||
return captureMigrationFailure(m, fmt.Errorf(
|
||||
"migration failed and database is now in dirty state at version %d. "+
|
||||
"To fix this:\n"+
|
||||
"1. Check if the migration partially applied changes and manually fix if needed\n"+
|
||||
"2. Use the force command to set the version to the last successful migration (usually %d):\n"+
|
||||
" ./scripts/migrate.sh force %d\n"+
|
||||
" Or if using make: make migrate-force version=%d\n"+
|
||||
"3. After fixing, restart the application to retry the migration\n"+
|
||||
"Or enable AutoRecoverDirty option to automatically retry",
|
||||
currentVersion,
|
||||
forceVersion,
|
||||
forceVersion,
|
||||
forceVersion,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
return captureMigrationFailure(m, fmt.Errorf("failed to run migrations: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Get current version after migration
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil && err != migrate.ErrNilVersion {
|
||||
return captureMigrationFailure(m, fmt.Errorf("failed to get migration version: %w", err))
|
||||
}
|
||||
|
||||
setMigrationState(version, dirty, "", true)
|
||||
|
||||
if oldVersion != version {
|
||||
logger.Infof(ctx, "Database migrated from version %d to %d", oldVersion, version)
|
||||
} else {
|
||||
logger.Infof(ctx, "Database is up to date (version: %d)", version)
|
||||
}
|
||||
|
||||
if dirty {
|
||||
logger.Warnf(ctx, "Database is in dirty state! Manual intervention may be required.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoverFromDirtyState attempts to recover from a dirty migration state
|
||||
// by forcing to the previous version and allowing the migration to be retried
|
||||
func recoverFromDirtyState(ctx context.Context, m *migrate.Migrate, dirtyVersion uint) error {
|
||||
// Special case: if dirty at version 0 (init migration), we cannot go back further
|
||||
// The only option is to force to version 0 and retry, but this requires the migration to be idempotent
|
||||
if dirtyVersion == 0 {
|
||||
logger.Warnf(ctx, "Database is in dirty state at version 0 (init migration). "+
|
||||
"This is the initial migration, cannot rollback further. "+
|
||||
"Will attempt to clear dirty flag and retry. "+
|
||||
"Note: This only works if the init migration uses IF NOT EXISTS clauses.")
|
||||
|
||||
// Force to version -1 (no version) to allow re-running version 0
|
||||
// This effectively tells migrate that no migrations have been applied
|
||||
if err := m.Force(-1); err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to recover from dirty state at version 0. "+
|
||||
"Manual intervention required:\n"+
|
||||
"1. Check what was partially created in the database\n"+
|
||||
"2. Either drop all created objects and retry, or\n"+
|
||||
"3. Manually complete the migration and run: ./scripts/migrate.sh force 0\n"+
|
||||
"Error: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Cleared migration state, will retry from version 0")
|
||||
return nil
|
||||
}
|
||||
|
||||
forceVersion := int(dirtyVersion) - 1
|
||||
|
||||
logger.Warnf(ctx, "Database is in dirty state at version %d, attempting auto-recovery by forcing to version %d",
|
||||
dirtyVersion, forceVersion)
|
||||
|
||||
// Force to previous version to clear dirty state
|
||||
if err := m.Force(forceVersion); err != nil {
|
||||
return fmt.Errorf("failed to force migration version during recovery: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Successfully forced migration to version %d, migration will be retried", forceVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMigrationVersion returns the current migration version
|
||||
func GetMigrationVersion() (uint, bool, error) {
|
||||
dbURL := fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"),
|
||||
os.Getenv("DB_HOST"),
|
||||
os.Getenv("DB_PORT"),
|
||||
os.Getenv("DB_NAME"),
|
||||
)
|
||||
|
||||
migrationsPath := "file://migrations/versioned"
|
||||
|
||||
m, err := migrate.New(migrationsPath, dbURL)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
return version, dirty, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSQLiteMigrationsIncludeAutoTagConfig(t *testing.T) {
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
require.NoError(t, err)
|
||||
previousDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(repoRoot))
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDir) })
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "migration.db")
|
||||
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", MigrationOptions{SQLiteDBPath: dbPath}))
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
rows, err := db.Query("PRAGMA table_info(knowledge_bases)")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
require.NoError(t, rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey))
|
||||
if name == "auto_tag_config" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
require.True(t, found, "SQLite migrations must create knowledge_bases.auto_tag_config")
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// versionedSQLiteTables is the set of tables that SQLite migrations must
|
||||
// create to stay in sync with the versioned (PostgreSQL) migrations:
|
||||
// 000041 task queue, 000053 system settings, 000055 processing spans,
|
||||
// 000063 knowledge multi-tags.
|
||||
var versionedSQLiteTables = []string{
|
||||
"task_pending_ops",
|
||||
"task_dead_letters",
|
||||
"system_settings",
|
||||
"knowledge_processing_spans",
|
||||
"knowledge_tag_relations",
|
||||
}
|
||||
|
||||
// versionedSQLiteColumns maps each existing table to the columns that the
|
||||
// versioned migrations add and the SQLite baseline was missing.
|
||||
var versionedSQLiteColumns = map[string][]string{
|
||||
"tenants": {"api_principal_config"}, // 000064
|
||||
"users": {"is_system_admin"}, // 000053
|
||||
"knowledges": {"pending_subtasks_count"}, // 000056
|
||||
"messages": {"attachments", "usage"}, // 000034, 000085
|
||||
"tenant_invitations": {"token", "accepted_count"}, // 000054
|
||||
"embed_channels": {"allow_memory"}, // 000060
|
||||
"mcp_oauth_tokens": {"principal_type", "principal_id"}, // 000064
|
||||
"workbench_access_roles": {"permission_version"}, // 000215
|
||||
}
|
||||
|
||||
const expectedSQLiteMigrationVersion = 215
|
||||
|
||||
func TestSQLiteMigrationsCreateVersionedSchema(t *testing.T) {
|
||||
repoRoot := sqliteRepoRoot(t)
|
||||
chdirAndRestore(t, repoRoot)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", MigrationOptions{SQLiteDBPath: dbPath}))
|
||||
|
||||
db := openSQLiteDB(t, dbPath)
|
||||
version, dirty := sqliteMigrationState(t, db)
|
||||
require.Equal(t, expectedSQLiteMigrationVersion, version)
|
||||
require.False(t, dirty)
|
||||
|
||||
for _, table := range versionedSQLiteTables {
|
||||
require.Truef(t, sqliteTableExists(t, db, table), "SQLite migrations must create table %s", table)
|
||||
}
|
||||
for table, columns := range versionedSQLiteColumns {
|
||||
for _, column := range columns {
|
||||
require.Truef(
|
||||
t,
|
||||
sqliteColumnExists(t, db, table, column),
|
||||
"SQLite migrations must add column %s.%s",
|
||||
table,
|
||||
column,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertSQLiteShareLinkInvitationsWork(t, db)
|
||||
assertSQLiteMCPOAuthPrincipalUpsertWorks(t, db)
|
||||
require.False(t, sqliteColumnExists(t, db, "knowledges", "tag_id"),
|
||||
"SQLite migrations must drop legacy knowledges.tag_id after multi-tag migration")
|
||||
}
|
||||
|
||||
func TestSQLiteMigrationsUpgradeV4PreservesData(t *testing.T) {
|
||||
repoRoot := sqliteRepoRoot(t)
|
||||
|
||||
// Build a legacy v4 migration root (000000_init .. 000004_memory) so we
|
||||
// can prove the new migrations upgrade an existing Lite database without
|
||||
// replaying the baseline.
|
||||
legacyRoot := copySQLiteMigrationsV4(t, repoRoot)
|
||||
chdirAndRestore(t, legacyRoot)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "upgrade.db")
|
||||
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", MigrationOptions{SQLiteDBPath: dbPath}))
|
||||
|
||||
db := openSQLiteDB(t, dbPath)
|
||||
versionBefore, dirtyBefore := sqliteMigrationState(t, db)
|
||||
require.Equal(t, 4, versionBefore)
|
||||
require.False(t, dirtyBefore)
|
||||
_, err := db.Exec("INSERT INTO tenants (name, business) VALUES (?, ?)", "upgrade-sentinel", "migration-test")
|
||||
require.NoError(t, err)
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO knowledges (id, tenant_id, knowledge_base_id, type, title, source, tag_id) "+
|
||||
"VALUES (?, 1, ?, 'document', 'tagged-doc', 'manual', ?)",
|
||||
"legacy-knowledge-1", "legacy-kb-1", "legacy-tag-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run the full migration set from the repo root.
|
||||
chdirAndRestore(t, repoRoot)
|
||||
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", MigrationOptions{SQLiteDBPath: dbPath}))
|
||||
|
||||
db = openSQLiteDB(t, dbPath)
|
||||
versionAfter, dirtyAfter := sqliteMigrationState(t, db)
|
||||
require.Equal(t, expectedSQLiteMigrationVersion, versionAfter)
|
||||
require.False(t, dirtyAfter)
|
||||
|
||||
for _, table := range versionedSQLiteTables {
|
||||
require.Truef(t, sqliteTableExists(t, db, table), "upgraded SQLite DB must have table %s", table)
|
||||
}
|
||||
for table, columns := range versionedSQLiteColumns {
|
||||
for _, column := range columns {
|
||||
require.Truef(
|
||||
t,
|
||||
sqliteColumnExists(t, db, table, column),
|
||||
"upgraded SQLite DB must have column %s.%s",
|
||||
table,
|
||||
column,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var sentinelName string
|
||||
require.NoError(t, db.QueryRow("SELECT name FROM tenants WHERE business = ?", "migration-test").Scan(&sentinelName))
|
||||
require.Equal(t, "upgrade-sentinel", sentinelName)
|
||||
|
||||
var relationCount int
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT COUNT(*) FROM knowledge_tag_relations WHERE knowledge_id = ? AND tag_id = ?",
|
||||
"legacy-knowledge-1", "legacy-tag-1",
|
||||
).Scan(&relationCount))
|
||||
require.Equal(t, 1, relationCount)
|
||||
require.False(t, sqliteColumnExists(t, db, "knowledges", "tag_id"))
|
||||
}
|
||||
|
||||
func sqliteRepoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
require.NoError(t, err)
|
||||
return repoRoot
|
||||
}
|
||||
|
||||
func chdirAndRestore(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
previousDir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDir) })
|
||||
}
|
||||
|
||||
func openSQLiteDB(t *testing.T, dbPath string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func sqliteMigrationState(t *testing.T, db *sql.DB) (version int, dirty bool) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.QueryRow("SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty))
|
||||
return version, dirty
|
||||
}
|
||||
|
||||
func sqliteTableExists(t *testing.T, db *sql.DB, table string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
table,
|
||||
).Scan(&n))
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func sqliteColumnExists(t *testing.T, db *sql.DB, table, column string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
||||
table,
|
||||
column,
|
||||
).Scan(&n))
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func assertSQLiteShareLinkInvitationsWork(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
_, err := db.Exec("INSERT INTO tenants (name, business) VALUES (?, ?)", "share-link-tenant", "share-link-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := "2099-01-01 00:00:00"
|
||||
shareLinkInsert := "INSERT INTO tenant_invitations " +
|
||||
"(tenant_id, invitee_user_id, token, role, status, expires_at) " +
|
||||
"VALUES (1, '', ?, 'member', 'pending', ?)"
|
||||
_, err = db.Exec(shareLinkInsert, "token-a", expiresAt)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Exec(shareLinkInsert, "token-b", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
var count int
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT COUNT(*) FROM tenant_invitations WHERE tenant_id = 1 AND invitee_user_id = '' AND status = 'pending'",
|
||||
).Scan(&count))
|
||||
require.Equal(t, 2, count)
|
||||
}
|
||||
|
||||
func assertSQLiteMCPOAuthPrincipalUpsertWorks(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO mcp_services (id, tenant_id, name, transport_type) VALUES (?, 1, 'svc', 'http')",
|
||||
"svc-migration-1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenInsertPrefix := "INSERT INTO mcp_oauth_tokens " +
|
||||
"(id, tenant_id, user_id, service_id, principal_type, principal_id, access_token) "
|
||||
_, err = db.Exec(
|
||||
tokenInsertPrefix +
|
||||
"VALUES ('tok-1', 1, 'u1', 'svc-migration-1', 'web_user', 'u1', 'token-1')",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Exec(
|
||||
tokenInsertPrefix +
|
||||
"VALUES ('tok-2', 1, 'u1', 'svc-migration-1', 'web_user', 'u1', 'token-2') " +
|
||||
"ON CONFLICT(tenant_id, principal_type, principal_id, service_id) " +
|
||||
"DO UPDATE SET access_token = excluded.access_token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var accessToken string
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT access_token FROM mcp_oauth_tokens "+
|
||||
"WHERE tenant_id = 1 AND principal_type = 'web_user' "+
|
||||
"AND principal_id = 'u1' AND service_id = 'svc-migration-1'",
|
||||
).Scan(&accessToken))
|
||||
require.Equal(t, "token-2", accessToken)
|
||||
|
||||
var rowCount int
|
||||
require.NoError(t, db.QueryRow(
|
||||
"SELECT COUNT(*) FROM mcp_oauth_tokens WHERE tenant_id = 1 AND service_id = 'svc-migration-1'",
|
||||
).Scan(&rowCount))
|
||||
require.Equal(t, 1, rowCount)
|
||||
}
|
||||
|
||||
func copySQLiteMigrationsV4(t *testing.T, repoRoot string) string {
|
||||
t.Helper()
|
||||
dest := t.TempDir()
|
||||
srcDir := filepath.Join(repoRoot, "migrations", "sqlite")
|
||||
destDir := filepath.Join(dest, "migrations", "sqlite")
|
||||
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||
|
||||
legacy := []string{
|
||||
"000000_init.up.sql",
|
||||
"000001_remove_wiki_log.up.sql",
|
||||
"000002_knowledge_folder_path.up.sql",
|
||||
"000003_knowledge_base_auto_tag_config.up.sql",
|
||||
"000004_memory.up.sql",
|
||||
}
|
||||
for _, name := range legacy {
|
||||
data, err := os.ReadFile(filepath.Join(srcDir, name))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(destDir, name), data, 0o600))
|
||||
}
|
||||
return dest
|
||||
}
|
||||
Reference in New Issue
Block a user