Files
WeKnora/internal/database/migration_sqlite_versioned_schema_test.go
T
wizardchen 2a40cc37c0 fix(migrations): renumber the context checkpoint migration to 000105
#3406 (000104_skill_served_version) and #3407
(000104_message_context_checkpoint) each took version 104 and were merged
eleven minutes apart. golang-migrate refuses to load a directory with a
duplicate version ("duplicate migration file"), so every PostgreSQL
deployment of main fails to migrate.

Renumber the later of the two. It is safe for every deployment that could
exist: one that migrated between the two merges applied the skill
migration as 104 and now applies this one as 105, and after the second
merge main could not load its migrations at all, so no deployment applied
this file as 104. The migration is ADD COLUMN IF NOT EXISTS either way.

Add a test that loads both migration directories. Pull request CI runs on
the merge with main, so a second PR taking an already merged number now
fails before it lands.
2026-09-18 21:26:26 +08:00

328 lines
11 KiB
Go

package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"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, 000093 browser authorization, 000103 message
// artifacts.
var versionedSQLiteTables = []string{
"memory_extraction_sessions",
"task_pending_ops",
"task_dead_letters",
"system_settings",
"knowledge_processing_spans",
"knowledge_tag_relations",
"browser_devices",
"browser_pairings",
"browser_task_interruptions",
"fork_snapshot_leases",
"mcp_endpoints",
"message_artifacts",
}
// versionedSQLiteColumns maps each existing table to the columns that the
// versioned migrations add and the SQLite baseline was missing.
var versionedSQLiteColumns = map[string][]string{
"memory_subjects": {"extraction_state"}, // 000094
"memory_items": {"replaces_id"}, // 000094
"tenants": {"api_principal_config"}, // 000064
"users": {"is_system_admin"}, // 000053
"knowledges": {"pending_subtasks_count", "profile"}, // 000056, 000101
"knowledge_bases": {"profile_config", "generated_profile"}, // 000101
"messages": {"attachments", "usage", "sandbox_checkpoint", "context_checkpoint"}, // 000034/085/097/105
"sessions": {"parent_session_id", "forked_from_message_id", "fork_bootstrap"}, // 000097
"tenant_invitations": {"token", "accepted_count"}, // 000054
"embed_channels": {"allow_memory"}, // 000060
"mcp_oauth_tokens": {"principal_type", "principal_id"}, // 000064
"mcp_tool_approvals": {"enabled"}, // 000091
}
const expectedSQLiteMigrationVersion = 24
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 TestSQLiteMigrationsUpgradeV16AddsSessionForkColumns(t *testing.T) {
repoRoot := sqliteRepoRoot(t)
legacyRoot := copySQLiteMigrationsThrough(t, repoRoot, 16)
chdirAndRestore(t, legacyRoot)
dbPath := filepath.Join(t.TempDir(), "upgrade-v16.db")
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", MigrationOptions{SQLiteDBPath: dbPath}))
db := openSQLiteDB(t, dbPath)
versionBefore, dirtyBefore := sqliteMigrationState(t, db)
require.Equal(t, 16, versionBefore)
require.False(t, dirtyBefore)
require.False(t, sqliteColumnExists(t, db, "sessions", "parent_session_id"))
require.False(t, sqliteColumnExists(t, db, "sessions", "forked_from_message_id"))
require.False(t, sqliteColumnExists(t, db, "sessions", "fork_bootstrap"))
require.False(t, sqliteColumnExists(t, db, "messages", "sandbox_checkpoint"))
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 _, column := range versionedSQLiteColumns["sessions"] {
require.Truef(
t,
sqliteColumnExists(t, db, "sessions", column),
"upgraded SQLite DB must have column sessions.%s",
column,
)
}
require.True(t, sqliteColumnExists(t, db, "messages", "sandbox_checkpoint"))
}
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()
return copySQLiteMigrationsThrough(t, repoRoot, 4)
}
func copySQLiteMigrationsThrough(t *testing.T, repoRoot string, maxVersion int) 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))
entries, err := os.ReadDir(srcDir)
require.NoError(t, err)
copied := 0
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".up.sql") {
continue
}
var version int
_, scanErr := fmt.Sscanf(name, "%d_", &version)
require.NoError(t, scanErr, "sqlite migration filename %s", name)
if version > maxVersion {
continue
}
data, readErr := os.ReadFile(filepath.Join(srcDir, name))
require.NoError(t, readErr)
require.NoError(t, os.WriteFile(filepath.Join(destDir, name), data, 0o600))
copied++
}
require.Greater(t, copied, 0)
return dest
}