package main import ( "database/sql" "fmt" "net/url" "os" "strings" _ "github.com/mattn/go-sqlite3" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/db" ) const ( batchSize = 1000 expectedMessageSchemaVersion = 14 expectedUserSchemaVersion = 6 expectedWebPushSchemaVersion = 1 ) var flags = []cli.Flag{ &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "path to server.yml config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, Usage: "PostgreSQL connection string"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file"}, Usage: "SQLite message cache file path"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file"}, Usage: "SQLite user/auth database file path"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, Usage: "SQLite web push database file path"}), } func main() { app := &cli.App{ Name: "pgimport", Usage: "SQLite to PostgreSQL migration tool for ntfy", UsageText: "pgimport [OPTIONS]", Flags: flags, Before: loadConfigFile("config", flags), Action: execImport, } if err := app.Run(os.Args); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func execImport(c *cli.Context) error { databaseURL := c.String("database-url") cacheFile := c.String("cache-file") authFile := c.String("auth-file") webPushFile := c.String("web-push-file") if databaseURL == "" { return fmt.Errorf("database-url must be set (via --database-url or config file)") } if cacheFile == "" && authFile == "" && webPushFile == "" { return fmt.Errorf("at least one of --cache-file, --auth-file, or --web-push-file must be set") } fmt.Println("pgimport - SQLite to PostgreSQL migration tool for ntfy") fmt.Println() fmt.Println("Sources:") printSource(" Cache file: ", cacheFile) printSource(" Auth file: ", authFile) printSource(" Web push file: ", webPushFile) fmt.Println() fmt.Println("Target:") fmt.Printf(" Database URL: %s\n", maskPassword(databaseURL)) fmt.Println() fmt.Println("This will import data from the SQLite databases into PostgreSQL.") fmt.Print("Make sure ntfy is not running. Continue? (y/n): ") var answer string fmt.Scanln(&answer) if strings.TrimSpace(strings.ToLower(answer)) != "y" { fmt.Println("Aborted.") return nil } fmt.Println() pgDB, err := db.OpenPostgres(databaseURL) if err != nil { return fmt.Errorf("cannot connect to PostgreSQL: %w", err) } defer pgDB.Close() if authFile != "" { if err := verifySchemaVersion(pgDB, "user", expectedUserSchemaVersion); err != nil { return err } if err := importUsers(authFile, pgDB); err != nil { return fmt.Errorf("cannot import users: %w", err) } } if cacheFile != "" { if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil { return err } if err := importMessages(cacheFile, pgDB); err != nil { return fmt.Errorf("cannot import messages: %w", err) } } if webPushFile != "" { if err := verifySchemaVersion(pgDB, "webpush", expectedWebPushSchemaVersion); err != nil { return err } if err := importWebPush(webPushFile, pgDB); err != nil { return fmt.Errorf("cannot import web push subscriptions: %w", err) } } fmt.Println() fmt.Println("Verifying migration ...") failed := false if authFile != "" { if err := verifyUsers(authFile, pgDB, &failed); err != nil { return fmt.Errorf("cannot verify users: %w", err) } } if cacheFile != "" { if err := verifyMessages(cacheFile, pgDB, &failed); err != nil { return fmt.Errorf("cannot verify messages: %w", err) } } if webPushFile != "" { if err := verifyWebPush(webPushFile, pgDB, &failed); err != nil { return fmt.Errorf("cannot verify web push: %w", err) } } fmt.Println() if failed { return fmt.Errorf("verification FAILED, see above for details") } fmt.Println("Verification successful. Migration complete.") return nil } func loadConfigFile(configFlag string, flags []cli.Flag) cli.BeforeFunc { return func(c *cli.Context) error { configFile := c.String(configFlag) if configFile == "" { return nil } if _, err := os.Stat(configFile); os.IsNotExist(err) { return fmt.Errorf("config file %s does not exist", configFile) } inputSource, err := newYamlSourceFromFile(configFile, flags) if err != nil { return err } return altsrc.ApplyInputSourceValues(c, inputSource, flags) } } func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) { var rawConfig map[any]any b, err := os.ReadFile(file) if err != nil { return nil, err } if err := yaml.Unmarshal(b, &rawConfig); err != nil { return nil, err } for _, f := range flags { flagName := f.Names()[0] for _, flagAlias := range f.Names()[1:] { if _, ok := rawConfig[flagAlias]; ok { rawConfig[flagName] = rawConfig[flagAlias] } } } return altsrc.NewMapInputSource(file, rawConfig), nil } func verifySchemaVersion(pgDB *sql.DB, store string, expected int) error { var version int err := pgDB.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version) if err != nil { return fmt.Errorf("cannot read %s schema version from PostgreSQL (is the schema set up?): %w", store, err) } if version != expected { return fmt.Errorf("%s schema version mismatch: expected %d, got %d", store, expected, version) } return nil } func printSource(label, path string) { if path == "" { fmt.Printf("%s(not set, skipping)\n", label) } else if _, err := os.Stat(path); os.IsNotExist(err) { fmt.Printf("%s%s (NOT FOUND, skipping)\n", label, path) } else { fmt.Printf("%s%s\n", label, path) } } func maskPassword(databaseURL string) string { u, err := url.Parse(databaseURL) if err != nil { return databaseURL } if u.User != nil { if _, hasPass := u.User.Password(); hasPass { masked := u.Scheme + "://" + u.User.Username() + ":****@" + u.Host + u.Path if u.RawQuery != "" { masked += "?" + u.RawQuery } return masked } } return u.String() } func openSQLite(filename string) (*sql.DB, error) { if _, err := os.Stat(filename); os.IsNotExist(err) { return nil, fmt.Errorf("file %s does not exist", filename) } return sql.Open("sqlite3", filename+"?mode=ro") } // User import func importUsers(sqliteFile string, pgDB *sql.DB) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { fmt.Printf("Skipping user import: %s\n", err) return nil } defer sqlDB.Close() fmt.Printf("Importing users from %s ...\n", sqliteFile) count, err := importTiers(sqlDB, pgDB) if err != nil { return fmt.Errorf("importing tiers: %w", err) } fmt.Printf(" Imported %d tiers\n", count) count, err = importUserRows(sqlDB, pgDB) if err != nil { return fmt.Errorf("importing users: %w", err) } fmt.Printf(" Imported %d users\n", count) count, err = importUserAccess(sqlDB, pgDB) if err != nil { return fmt.Errorf("importing user access: %w", err) } fmt.Printf(" Imported %d access entries\n", count) count, err = importUserTokens(sqlDB, pgDB) if err != nil { return fmt.Errorf("importing user tokens: %w", err) } fmt.Printf(" Imported %d tokens\n", count) count, err = importUserPhones(sqlDB, pgDB) if err != nil { return fmt.Errorf("importing user phones: %w", err) } fmt.Printf(" Imported %d phone numbers\n", count) return nil } func importTiers(sqlDB, pgDB *sql.DB) (int, error) { rows, err := sqlDB.Query(`SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier`) if err != nil { return 0, err } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare(`INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO NOTHING`) if err != nil { return 0, err } defer stmt.Close() count := 0 for rows.Next() { var id, code, name string var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit int64 var attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit int64 var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return 0, err } if _, err := stmt.Exec(id, code, name, messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeMonthlyPriceID, stripeYearlyPriceID); err != nil { return 0, err } count++ } return count, tx.Commit() } func importUserRows(sqlDB, pgDB *sql.DB) (int, error) { rows, err := sqlDB.Query(`SELECT id, user, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id FROM user`) if err != nil { return 0, err } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO "user" (id, user_name, pass, role, prefs, sync_topic, provisioned, stats_messages, stats_emails, stats_calls, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_subscription_interval, stripe_subscription_paid_until, stripe_subscription_cancel_at, created, deleted, tier_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (id) DO NOTHING `) if err != nil { return 0, err } defer stmt.Close() count := 0 for rows.Next() { var id, userName, pass, role, prefs, syncTopic string var provisioned int var statsMessages, statsEmails, statsCalls int64 var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval sql.NullString var stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64 var created int64 var deleted sql.NullInt64 var tierID sql.NullString if err := rows.Scan(&id, &userName, &pass, &role, &prefs, &syncTopic, &provisioned, &statsMessages, &statsEmails, &statsCalls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &created, &deleted, &tierID); err != nil { return 0, err } provisionedBool := provisioned != 0 if _, err := stmt.Exec(id, userName, pass, role, prefs, syncTopic, provisionedBool, statsMessages, statsEmails, statsCalls, stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, created, deleted, tierID); err != nil { return 0, err } count++ } return count, tx.Commit() } func importUserAccess(sqlDB, pgDB *sql.DB) (int, error) { rows, err := sqlDB.Query(`SELECT a.user_id, a.topic, a.read, a.write, a.owner_user_id, a.provisioned FROM user_access a JOIN user u ON u.id = a.user_id`) if err != nil { return 0, err } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare(`INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, topic) DO NOTHING`) if err != nil { return 0, err } defer stmt.Close() count := 0 for rows.Next() { var userID, topic string var read, write, provisioned int var ownerUserID sql.NullString if err := rows.Scan(&userID, &topic, &read, &write, &ownerUserID, &provisioned); err != nil { return 0, err } readBool := read != 0 writeBool := write != 0 provisionedBool := provisioned != 0 if _, err := stmt.Exec(userID, topic, readBool, writeBool, ownerUserID, provisionedBool); err != nil { return 0, err } count++ } return count, tx.Commit() } func importUserTokens(sqlDB, pgDB *sql.DB) (int, error) { rows, err := sqlDB.Query(`SELECT t.user_id, t.token, t.label, t.last_access, t.last_origin, t.expires, t.provisioned FROM user_token t JOIN user u ON u.id = t.user_id`) if err != nil { return 0, err } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare(`INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires, provisioned) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, token) DO NOTHING`) if err != nil { return 0, err } defer stmt.Close() count := 0 for rows.Next() { var userID, token, label, lastOrigin string var lastAccess, expires int64 var provisioned int if err := rows.Scan(&userID, &token, &label, &lastAccess, &lastOrigin, &expires, &provisioned); err != nil { return 0, err } provisionedBool := provisioned != 0 if _, err := stmt.Exec(userID, token, label, lastAccess, lastOrigin, expires, provisionedBool); err != nil { return 0, err } count++ } return count, tx.Commit() } func importUserPhones(sqlDB, pgDB *sql.DB) (int, error) { rows, err := sqlDB.Query(`SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id`) if err != nil { return 0, err } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return 0, err } defer tx.Rollback() stmt, err := tx.Prepare(`INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2) ON CONFLICT (user_id, phone_number) DO NOTHING`) if err != nil { return 0, err } defer stmt.Close() count := 0 for rows.Next() { var userID, phoneNumber string if err := rows.Scan(&userID, &phoneNumber); err != nil { return 0, err } if _, err := stmt.Exec(userID, phoneNumber); err != nil { return 0, err } count++ } return count, tx.Commit() } // Message import func importMessages(sqliteFile string, pgDB *sql.DB) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { fmt.Printf("Skipping message import: %s\n", err) return nil } defer sqlDB.Close() fmt.Printf("Importing messages from %s ...\n", sqliteFile) rows, err := sqlDB.Query(`SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published FROM messages`) if err != nil { return fmt.Errorf("querying messages: %w", err) } defer rows.Close() if _, err := pgDB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_mid_unique ON message (mid)`); err != nil { return fmt.Errorf("creating unique index on mid: %w", err) } insertQuery := `INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) ON CONFLICT (mid) DO NOTHING` count := 0 batchCount := 0 tx, err := pgDB.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare(insertQuery) if err != nil { return err } defer stmt.Close() for rows.Next() { var mid, sequenceID, event, topic, message, title, tags, click, icon, actions string var attachmentName, attachmentType, attachmentURL, sender, userID, contentType, encoding string var msgTime, expires, attachmentExpires int64 var priority int var attachmentSize int64 var attachmentDeleted, published int if err := rows.Scan(&mid, &sequenceID, &msgTime, &event, &expires, &topic, &message, &title, &priority, &tags, &click, &icon, &actions, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentDeleted, &sender, &userID, &contentType, &encoding, &published); err != nil { return fmt.Errorf("scanning message: %w", err) } mid = toUTF8(mid) sequenceID = toUTF8(sequenceID) event = toUTF8(event) topic = toUTF8(topic) message = toUTF8(message) title = toUTF8(title) tags = toUTF8(tags) click = toUTF8(click) icon = toUTF8(icon) actions = toUTF8(actions) attachmentName = toUTF8(attachmentName) attachmentType = toUTF8(attachmentType) attachmentURL = toUTF8(attachmentURL) sender = toUTF8(sender) userID = toUTF8(userID) contentType = toUTF8(contentType) encoding = toUTF8(encoding) attachmentDeletedBool := attachmentDeleted != 0 publishedBool := published != 0 if _, err := stmt.Exec(mid, sequenceID, msgTime, event, expires, topic, message, title, priority, tags, click, icon, actions, attachmentName, attachmentType, attachmentSize, attachmentExpires, attachmentURL, attachmentDeletedBool, sender, userID, contentType, encoding, publishedBool); err != nil { return fmt.Errorf("inserting message: %w", err) } count++ batchCount++ if batchCount >= batchSize { stmt.Close() if err := tx.Commit(); err != nil { return fmt.Errorf("committing message batch: %w", err) } fmt.Printf(" ... %d messages\n", count) tx, err = pgDB.Begin() if err != nil { return err } stmt, err = tx.Prepare(insertQuery) if err != nil { return err } batchCount = 0 } } if batchCount > 0 { stmt.Close() if err := tx.Commit(); err != nil { return fmt.Errorf("committing final message batch: %w", err) } } fmt.Printf(" Imported %d messages\n", count) var statsValue int64 err = sqlDB.QueryRow(`SELECT value FROM stats WHERE key = 'messages'`).Scan(&statsValue) if err == nil { if _, err := pgDB.Exec(`UPDATE message_stats SET value = $1 WHERE key = 'messages'`, statsValue); err != nil { return fmt.Errorf("updating message stats: %w", err) } fmt.Printf(" Updated message stats (count: %d)\n", statsValue) } return nil } // Web push import func importWebPush(sqliteFile string, pgDB *sql.DB) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { fmt.Printf("Skipping web push import: %s\n", err) return nil } defer sqlDB.Close() fmt.Printf("Importing web push subscriptions from %s ...\n", sqliteFile) rows, err := sqlDB.Query(`SELECT id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at FROM subscription`) if err != nil { return fmt.Errorf("querying subscriptions: %w", err) } defer rows.Close() tx, err := pgDB.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare(`INSERT INTO webpush_subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`) if err != nil { return err } defer stmt.Close() count := 0 for rows.Next() { var id, endpoint, keyAuth, keyP256dh, userID, subscriberIP string var updatedAt, warnedAt int64 if err := rows.Scan(&id, &endpoint, &keyAuth, &keyP256dh, &userID, &subscriberIP, &updatedAt, &warnedAt); err != nil { return fmt.Errorf("scanning subscription: %w", err) } if _, err := stmt.Exec(id, endpoint, keyAuth, keyP256dh, userID, subscriberIP, updatedAt, warnedAt); err != nil { return fmt.Errorf("inserting subscription: %w", err) } count++ } stmt.Close() if err := tx.Commit(); err != nil { return fmt.Errorf("committing subscriptions: %w", err) } fmt.Printf(" Imported %d subscriptions\n", count) topicRows, err := sqlDB.Query(`SELECT subscription_id, topic FROM subscription_topic`) if err != nil { return fmt.Errorf("querying subscription topics: %w", err) } defer topicRows.Close() tx, err = pgDB.Begin() if err != nil { return err } defer tx.Rollback() stmt, err = tx.Prepare(`INSERT INTO webpush_subscription_topic (subscription_id, topic) VALUES ($1, $2) ON CONFLICT (subscription_id, topic) DO NOTHING`) if err != nil { return err } defer stmt.Close() topicCount := 0 for topicRows.Next() { var subscriptionID, topic string if err := topicRows.Scan(&subscriptionID, &topic); err != nil { return fmt.Errorf("scanning subscription topic: %w", err) } if _, err := stmt.Exec(subscriptionID, topic); err != nil { return fmt.Errorf("inserting subscription topic: %w", err) } topicCount++ } stmt.Close() if err := tx.Commit(); err != nil { return fmt.Errorf("committing subscription topics: %w", err) } fmt.Printf(" Imported %d subscription topics\n", topicCount) return nil } func toUTF8(s string) string { return strings.ToValidUTF8(s, "\uFFFD") } // Verification func verifyUsers(sqliteFile string, pgDB *sql.DB, failed *bool) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { return nil } defer sqlDB.Close() verifyCount(sqlDB, pgDB, "tier", `SELECT COUNT(*) FROM tier`, `SELECT COUNT(*) FROM tier`, failed) verifyContent(sqlDB, pgDB, "tier", `SELECT id, code, name FROM tier ORDER BY id`, `SELECT id, code, name FROM tier ORDER BY id COLLATE "C"`, failed) verifyCount(sqlDB, pgDB, "user", `SELECT COUNT(*) FROM user`, `SELECT COUNT(*) FROM "user"`, failed) verifyContent(sqlDB, pgDB, "user", `SELECT id, user, role, sync_topic FROM user ORDER BY id`, `SELECT id, user_name, role, sync_topic FROM "user" ORDER BY id COLLATE "C"`, failed) verifyCount(sqlDB, pgDB, "user_access", `SELECT COUNT(*) FROM user_access a JOIN user u ON u.id = a.user_id`, `SELECT COUNT(*) FROM user_access`, failed) verifyContent(sqlDB, pgDB, "user_access", `SELECT a.user_id, a.topic FROM user_access a JOIN user u ON u.id = a.user_id ORDER BY a.user_id, a.topic`, `SELECT user_id, topic FROM user_access ORDER BY user_id COLLATE "C", topic COLLATE "C"`, failed) verifyCount(sqlDB, pgDB, "user_token", `SELECT COUNT(*) FROM user_token t JOIN user u ON u.id = t.user_id`, `SELECT COUNT(*) FROM user_token`, failed) verifyContent(sqlDB, pgDB, "user_token", `SELECT t.user_id, t.token, t.label FROM user_token t JOIN user u ON u.id = t.user_id ORDER BY t.user_id, t.token`, `SELECT user_id, token, label FROM user_token ORDER BY user_id COLLATE "C", token COLLATE "C"`, failed) verifyCount(sqlDB, pgDB, "user_phone", `SELECT COUNT(*) FROM user_phone p JOIN user u ON u.id = p.user_id`, `SELECT COUNT(*) FROM user_phone`, failed) verifyContent(sqlDB, pgDB, "user_phone", `SELECT p.user_id, p.phone_number FROM user_phone p JOIN user u ON u.id = p.user_id ORDER BY p.user_id, p.phone_number`, `SELECT user_id, phone_number FROM user_phone ORDER BY user_id COLLATE "C", phone_number COLLATE "C"`, failed) return nil } func verifyMessages(sqliteFile string, pgDB *sql.DB, failed *bool) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { return nil } defer sqlDB.Close() verifyCount(sqlDB, pgDB, "messages", `SELECT COUNT(*) FROM messages`, `SELECT COUNT(*) FROM message`, failed) verifySampledMessages(sqlDB, pgDB, failed) return nil } func verifyWebPush(sqliteFile string, pgDB *sql.DB, failed *bool) error { sqlDB, err := openSQLite(sqliteFile) if err != nil { return nil } defer sqlDB.Close() verifyCount(sqlDB, pgDB, "subscription", `SELECT COUNT(*) FROM subscription`, `SELECT COUNT(*) FROM webpush_subscription`, failed) verifyContent(sqlDB, pgDB, "subscription", `SELECT id, endpoint, key_auth, key_p256dh, user_id FROM subscription ORDER BY id`, `SELECT id, endpoint, key_auth, key_p256dh, user_id FROM webpush_subscription ORDER BY id COLLATE "C"`, failed) verifyCount(sqlDB, pgDB, "subscription_topic", `SELECT COUNT(*) FROM subscription_topic`, `SELECT COUNT(*) FROM webpush_subscription_topic`, failed) verifyContent(sqlDB, pgDB, "subscription_topic", `SELECT subscription_id, topic FROM subscription_topic ORDER BY subscription_id, topic`, `SELECT subscription_id, topic FROM webpush_subscription_topic ORDER BY subscription_id COLLATE "C", topic COLLATE "C"`, failed) return nil } func verifyCount(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) { var sqliteCount, pgCount int64 if err := sqlDB.QueryRow(sqliteQuery).Scan(&sqliteCount); err != nil { fmt.Printf(" %-25s count ERROR reading SQLite: %s\n", table, err) *failed = true return } if err := pgDB.QueryRow(pgQuery).Scan(&pgCount); err != nil { fmt.Printf(" %-25s count ERROR reading PostgreSQL: %s\n", table, err) *failed = true return } if sqliteCount == pgCount { fmt.Printf(" %-25s count OK (%d rows)\n", table, pgCount) } else { fmt.Printf(" %-25s count MISMATCH: SQLite=%d, PostgreSQL=%d\n", table, sqliteCount, pgCount) *failed = true } } func verifyContent(sqlDB, pgDB *sql.DB, table, sqliteQuery, pgQuery string, failed *bool) { sqliteRows, err := sqlDB.Query(sqliteQuery) if err != nil { fmt.Printf(" %-25s content ERROR reading SQLite: %s\n", table, err) *failed = true return } defer sqliteRows.Close() pgRows, err := pgDB.Query(pgQuery) if err != nil { fmt.Printf(" %-25s content ERROR reading PostgreSQL: %s\n", table, err) *failed = true return } defer pgRows.Close() cols, err := sqliteRows.Columns() if err != nil { fmt.Printf(" %-25s content ERROR reading columns: %s\n", table, err) *failed = true return } numCols := len(cols) rowNum := 0 mismatches := 0 for sqliteRows.Next() { rowNum++ if !pgRows.Next() { fmt.Printf(" %-25s content MISMATCH: PostgreSQL has fewer rows (at row %d)\n", table, rowNum) *failed = true return } sqliteVals := makeStringSlice(numCols) pgVals := makeStringSlice(numCols) if err := sqliteRows.Scan(sqliteVals...); err != nil { fmt.Printf(" %-25s content ERROR scanning SQLite row %d: %s\n", table, rowNum, err) *failed = true return } if err := pgRows.Scan(pgVals...); err != nil { fmt.Printf(" %-25s content ERROR scanning PostgreSQL row %d: %s\n", table, rowNum, err) *failed = true return } for i := 0; i < numCols; i++ { sv := *(sqliteVals[i].(*sql.NullString)) pv := *(pgVals[i].(*sql.NullString)) if sv != pv { mismatches++ if mismatches <= 3 { fmt.Printf(" %-25s content MISMATCH at row %d, col %s: SQLite=%q, PostgreSQL=%q\n", table, rowNum, cols[i], sv.String, pv.String) } } } } if pgRows.Next() { fmt.Printf(" %-25s content MISMATCH: PostgreSQL has more rows than SQLite\n", table) *failed = true return } if mismatches > 0 { if mismatches > 3 { fmt.Printf(" %-25s content ... and %d more mismatches\n", table, mismatches-3) } *failed = true } else { fmt.Printf(" %-25s content OK\n", table) } } func verifySampledMessages(sqlDB, pgDB *sql.DB, failed *bool) { rows, err := sqlDB.Query(`SELECT mid, topic, time, message, title, tags, priority FROM messages ORDER BY mid`) if err != nil { fmt.Printf(" %-25s content ERROR reading SQLite: %s\n", "messages (sampled)", err) *failed = true return } defer rows.Close() rowNum := 0 checked := 0 mismatches := 0 for rows.Next() { rowNum++ var mid, topic, message, title, tags string var msgTime int64 var priority int if err := rows.Scan(&mid, &topic, &msgTime, &message, &title, &tags, &priority); err != nil { fmt.Printf(" %-25s content ERROR scanning SQLite row %d: %s\n", "messages (sampled)", rowNum, err) *failed = true return } if rowNum%100 != 1 { continue } checked++ var pgTopic, pgMessage, pgTitle, pgTags string var pgTime int64 var pgPriority int err := pgDB.QueryRow(`SELECT topic, time, message, title, tags, priority FROM message WHERE mid = $1`, mid). Scan(&pgTopic, &pgTime, &pgMessage, &pgTitle, &pgTags, &pgPriority) if err == sql.ErrNoRows { mismatches++ if mismatches <= 3 { fmt.Printf(" %-25s content MISMATCH: mid=%s not found in PostgreSQL\n", "messages (sampled)", mid) } continue } else if err != nil { fmt.Printf(" %-25s content ERROR querying PostgreSQL for mid=%s: %s\n", "messages (sampled)", mid, err) *failed = true return } topic = toUTF8(topic) message = toUTF8(message) title = toUTF8(title) tags = toUTF8(tags) if topic != pgTopic || msgTime != pgTime || message != pgMessage || title != pgTitle || tags != pgTags || priority != pgPriority { mismatches++ if mismatches <= 3 { fmt.Printf(" %-25s content MISMATCH at mid=%s\n", "messages (sampled)", mid) } } } if mismatches > 0 { if mismatches > 3 { fmt.Printf(" %-25s content ... and %d more mismatches\n", "messages (sampled)", mismatches-3) } *failed = true } else { fmt.Printf(" %-25s content OK (%d samples checked)\n", "messages (sampled)", checked) } } func makeStringSlice(n int) []any { vals := make([]any, n) for i := range vals { vals[i] = &sql.NullString{} } return vals }