2026-03-02 20:15:35 -05:00
// pgimport is a one-off migration script to import ntfy data from SQLite to PostgreSQL.
// It is not a generic migration tool. It expects specific schema versions for each database
// (message cache v14, user db v6, web push v1) and will refuse to run if versions don't match.
2026-02-23 22:44:21 -05:00
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"
2026-03-02 19:52:36 -05:00
"heckel.io/ntfy/v2/db/pg"
2026-02-23 22:44:21 -05:00
)
const (
batchSize = 1000
expectedMessageSchemaVersion = 14
expectedUserSchemaVersion = 6
expectedWebPushSchemaVersion = 1
2026-03-06 14:46:53 -05:00
everyoneID = "u_everyone"
// Initial PostgreSQL schema for message store (from message/cache_postgres_schema.go)
createMessageSchemaQuery = `
CREATE TABLE IF NOT EXISTS message (
id BIGSERIAL PRIMARY KEY ,
mid TEXT NOT NULL ,
sequence_id TEXT NOT NULL ,
time BIGINT NOT NULL ,
event TEXT NOT NULL ,
expires BIGINT NOT NULL ,
topic TEXT NOT NULL ,
message TEXT NOT NULL ,
title TEXT NOT NULL ,
priority INT NOT NULL ,
tags TEXT NOT NULL ,
click TEXT NOT NULL ,
icon TEXT NOT NULL ,
actions TEXT NOT NULL ,
attachment_name TEXT NOT NULL ,
attachment_type TEXT NOT NULL ,
attachment_size BIGINT NOT NULL ,
attachment_expires BIGINT NOT NULL ,
attachment_url TEXT NOT NULL ,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE ,
sender TEXT NOT NULL ,
user_id TEXT NOT NULL ,
content_type TEXT NOT NULL ,
encoding TEXT NOT NULL ,
published BOOLEAN NOT NULL DEFAULT FALSE
) ;
CREATE INDEX IF NOT EXISTS idx_message_mid ON message ( mid ) ;
CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message ( sequence_id ) ;
CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message ( topic , published , time , id ) ;
CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message ( published , expires ) ;
CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message ( sender , attachment_expires ) WHERE user_id = ' ' ;
CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message ( user_id , attachment_expires ) ;
CREATE TABLE IF NOT EXISTS message_stats (
key TEXT PRIMARY KEY ,
value BIGINT
) ;
INSERT INTO message_stats ( key , value ) VALUES ( ' messages ' , 0 ) ;
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY ,
version INT NOT NULL
) ;
INSERT INTO schema_version ( store , version ) VALUES ( ' message ' , 14 ) ;
`
// Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go)
createUserSchemaQuery = `
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY ,
code TEXT NOT NULL ,
name TEXT NOT NULL ,
messages_limit BIGINT NOT NULL ,
messages_expiry_duration BIGINT NOT NULL ,
emails_limit BIGINT NOT NULL ,
calls_limit BIGINT NOT NULL ,
reservations_limit BIGINT NOT NULL ,
attachment_file_size_limit BIGINT NOT NULL ,
attachment_total_size_limit BIGINT NOT NULL ,
attachment_expiry_duration BIGINT NOT NULL ,
attachment_bandwidth_limit BIGINT NOT NULL ,
stripe_monthly_price_id TEXT ,
stripe_yearly_price_id TEXT ,
UNIQUE ( code ) ,
UNIQUE ( stripe_monthly_price_id ) ,
UNIQUE ( stripe_yearly_price_id )
) ;
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY ,
tier_id TEXT REFERENCES tier ( id ) ,
user_name TEXT NOT NULL UNIQUE ,
pass TEXT NOT NULL ,
role TEXT NOT NULL CHECK ( role IN ( ' anonymous ' , ' admin ' , ' user ' ) ) ,
prefs JSONB NOT NULL DEFAULT ' { } ' ,
sync_topic TEXT NOT NULL ,
provisioned BOOLEAN NOT NULL ,
stats_messages BIGINT NOT NULL DEFAULT 0 ,
stats_emails BIGINT NOT NULL DEFAULT 0 ,
stats_calls BIGINT NOT NULL DEFAULT 0 ,
stripe_customer_id TEXT UNIQUE ,
stripe_subscription_id TEXT UNIQUE ,
stripe_subscription_status TEXT ,
stripe_subscription_interval TEXT ,
stripe_subscription_paid_until BIGINT ,
stripe_subscription_cancel_at BIGINT ,
created BIGINT NOT NULL ,
deleted BIGINT
) ;
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL REFERENCES "user" ( id ) ON DELETE CASCADE ,
topic TEXT NOT NULL ,
read BOOLEAN NOT NULL ,
write BOOLEAN NOT NULL ,
owner_user_id TEXT REFERENCES "user" ( id ) ON DELETE CASCADE ,
provisioned BOOLEAN NOT NULL ,
PRIMARY KEY ( user_id , topic )
) ;
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL REFERENCES "user" ( id ) ON DELETE CASCADE ,
token TEXT NOT NULL UNIQUE ,
label TEXT NOT NULL ,
last_access BIGINT NOT NULL ,
last_origin TEXT NOT NULL ,
expires BIGINT NOT NULL ,
provisioned BOOLEAN NOT NULL ,
PRIMARY KEY ( user_id , token )
) ;
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL REFERENCES "user" ( id ) ON DELETE CASCADE ,
phone_number TEXT NOT NULL ,
PRIMARY KEY ( user_id , phone_number )
) ;
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY ,
version INT NOT NULL
) ;
INSERT INTO "user" ( id , user_name , pass , role , sync_topic , provisioned , created )
VALUES ( ' ` + everyoneID + ` ' , '*' , ' ' , ' anonymous ' , ' ' , false , EXTRACT ( EPOCH FROM NOW ( ) ) : : BIGINT )
ON CONFLICT ( id ) DO NOTHING ;
INSERT INTO schema_version ( store , version ) VALUES ( ' user ' , 6 ) ;
`
// Initial PostgreSQL schema for web push store (from webpush/store_postgres.go)
createWebPushSchemaQuery = `
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY ,
endpoint TEXT NOT NULL UNIQUE ,
key_auth TEXT NOT NULL ,
key_p256dh TEXT NOT NULL ,
user_id TEXT NOT NULL ,
subscriber_ip TEXT NOT NULL ,
updated_at BIGINT NOT NULL ,
warned_at BIGINT NOT NULL DEFAULT 0
) ;
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription ( subscriber_ip ) ;
CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription ( updated_at ) ;
CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription ( user_id ) ;
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription ( id ) ON DELETE CASCADE ,
topic TEXT NOT NULL ,
PRIMARY KEY ( subscription_id , topic )
) ;
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic ( topic ) ;
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY ,
version INT NOT NULL
) ;
INSERT INTO schema_version ( store , version ) VALUES ( ' webpush ' , 1 ) ;
`
2026-02-23 22:44:21 -05:00
)
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" } ) ,
2026-03-06 14:46:53 -05:00
& cli . BoolFlag { Name : "create-schema" , Usage : "create initial PostgreSQL schema before importing" } ,
2026-02-23 22:44:21 -05:00
}
func main ( ) {
app := & cli . App {
Name : "pgimport" ,
2026-03-02 20:15:35 -05:00
Usage : "One-off SQLite to PostgreSQL migration script for ntfy" ,
2026-02-23 22:44:21 -05:00
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 ( )
2026-03-11 21:07:58 -04:00
pgHost , err := pg . Open ( databaseURL )
2026-02-23 22:44:21 -05:00
if err != nil {
return fmt . Errorf ( "cannot connect to PostgreSQL: %w" , err )
}
2026-03-11 21:07:58 -04:00
pgDB := pgHost . DB
2026-02-23 22:44:21 -05:00
defer pgDB . Close ( )
2026-03-06 14:46:53 -05:00
if c . Bool ( "create-schema" ) {
if err := createSchema ( pgDB , cacheFile , authFile , webPushFile ) ; err != nil {
return fmt . Errorf ( "cannot create schema: %w" , err )
}
}
2026-02-23 22:44:21 -05:00
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
}
2026-03-06 14:46:53 -05:00
func createSchema ( pgDB * sql . DB , cacheFile , authFile , webPushFile string ) error {
fmt . Println ( "Creating initial PostgreSQL schema ..." )
// User schema must be created before message schema, because message_stats and
// schema_version use "INSERT INTO" without "ON CONFLICT", so user schema (which
// also creates the schema_version table) must come first.
if authFile != "" {
fmt . Println ( " Creating user schema ..." )
if _ , err := pgDB . Exec ( createUserSchemaQuery ) ; err != nil {
return fmt . Errorf ( "creating user schema: %w" , err )
}
}
if cacheFile != "" {
fmt . Println ( " Creating message schema ..." )
if _ , err := pgDB . Exec ( createMessageSchemaQuery ) ; err != nil {
return fmt . Errorf ( "creating message schema: %w" , err )
}
}
if webPushFile != "" {
fmt . Println ( " Creating web push schema ..." )
if _ , err := pgDB . Exec ( createWebPushSchemaQuery ) ; err != nil {
return fmt . Errorf ( "creating web push schema: %w" , err )
}
}
fmt . Println ( " Schema creation complete." )
fmt . Println ( )
return nil
}
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
verifyCount ( sqlDB , pgDB , "user" , ` SELECT COUNT(*) FROM user ` , ` SELECT COUNT(*) FROM "user" ` , failed )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
verifySampledMessages ( sqlDB , pgDB , failed )
2026-02-23 22:44:21 -05:00
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 )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
verifyCount ( sqlDB , pgDB , "subscription_topic" , ` SELECT COUNT(*) FROM subscription_topic ` , ` SELECT COUNT(*) FROM webpush_subscription_topic ` , failed )
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
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 {
2026-02-23 23:08:13 -05:00
fmt . Printf ( " %-25s count ERROR reading SQLite: %s\n" , table , err )
2026-02-23 22:44:21 -05:00
* failed = true
return
}
if err := pgDB . QueryRow ( pgQuery ) . Scan ( & pgCount ) ; err != nil {
2026-02-23 23:08:13 -05:00
fmt . Printf ( " %-25s count ERROR reading PostgreSQL: %s\n" , table , err )
2026-02-23 22:44:21 -05:00
* failed = true
return
}
if sqliteCount == pgCount {
2026-02-23 23:08:13 -05:00
fmt . Printf ( " %-25s count OK (%d rows)\n" , table , pgCount )
2026-02-23 22:44:21 -05:00
} else {
2026-02-23 23:08:13 -05:00
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 )
2026-02-23 22:44:21 -05:00
* failed = true
2026-02-23 23:08:13 -05:00
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 { }
2026-02-23 22:44:21 -05:00
}
2026-02-23 23:08:13 -05:00
return vals
2026-02-23 22:44:21 -05:00
}