diff --git a/Insomnia_2025-06-25.yaml b/Insomnia_2026-02-25.yaml similarity index 79% rename from Insomnia_2025-06-25.yaml rename to Insomnia_2026-02-25.yaml index 10cf257..77edd02 100644 --- a/Insomnia_2025-06-25.yaml +++ b/Insomnia_2026-02-25.yaml @@ -1,14 +1,15 @@ type: collection.insomnia.rest/5.0 +schema_version: "5.1" name: Scratch Pad meta: id: wrk_scratchpad - created: 1750805884848 - modified: 1750805884848 + created: 1771818346679 + modified: 1771818346679 description: "" collection: - name: Internal meta: - id: fld_3159eb11f7294b9199cf40ce3b1ec5f3 + id: fld_366cc23ffe9546379371e40c695ddfa2 created: 1702091260653 modified: 1702091260653 sortKey: -1702091260653 @@ -17,7 +18,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/internal/map/edana/1273 name: Map Verify meta: - id: req_777bfe1f5a79487b8900774313468de8 + id: req_81f6af598a504f91be75ab566ade0627 created: 1701386023339 modified: 1702091287346 isPrivate: false @@ -38,9 +39,9 @@ collection: - url: http://127.0.0.1:1337/api/v2/internal/character/ name: New Character meta: - id: req_9d205813904d4fefac747ba94f21be20 + id: req_b4937fca3b244d0ebc92d498b561f86b created: 1701668372562 - modified: 1750892580110 + modified: 1771885637277 isPrivate: false description: "" sortKey: -1702091266935 @@ -49,8 +50,8 @@ collection: mimeType: application/json text: |- { - "steamid": "1337", - "slot": 1, + "steamid": "76561197970271912", + "slot": 2, "size": 180, "data": "THIS_TEST_DATA2J____DJHDHJFJK ALTERNATE" } @@ -70,7 +71,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/internal/ban/21743647643 name: Ban Verify meta: - id: req_9cb08d62e2234fdbac083dfb56afb847 + id: req_a67772aef4fe4e888db727c53c810db2 created: 1701720818712 modified: 1702091289643 isPrivate: false @@ -88,12 +89,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/internal/character/531cdaaf-8368-47ca-b663-517af82d5ac6 + - url: http://127.0.0.1:1337/api/v2/internal/character/05752e13-31f4-4641-a93a-137785a37142 name: Update Character meta: - id: req_461281e9c7f34c1497e04d13264a5d66 + id: req_644de9363b6946e9b5e9387e52f5a1b1 created: 1701855673965 - modified: 1750806152058 + modified: 1771819157808 isPrivate: false description: "" sortKey: -1702091267035 @@ -102,8 +103,8 @@ collection: mimeType: application/json text: |- { - "size": 1, - "data": "UwU2" + "size": 156, + "data": "UwU2433" } headers: - name: Content-Type @@ -118,12 +119,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/internal/character/1337/65 + - url: http://127.0.0.1:1337/api/v2/internal/character/76561197970271912/0 name: Get Character meta: - id: req_29b4de3833cd451bb96dbfabff8c75f3 + id: req_251d3c7109f84c34a1143197616ff8e7 created: 1702000504553 - modified: 1750896147253 + modified: 1771836126507 isPrivate: false description: "" sortKey: -1702091266635 @@ -139,12 +140,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/internal/character/f69ffd70-6fdb-443c-a071-7f17565291ad + - url: http://127.0.0.1:1337/api/v2/internal/character/99d08d19-e67f-48f3-beb5-a424de24270e name: Delete Character meta: - id: req_49f5dccb45464f7dbcdf405c0adc623a + id: req_8661af7a260e4ee3b14763a182341e9d created: 1702014924846 - modified: 1750893194066 + modified: 1772004720934 isPrivate: false description: "" sortKey: -1702091266885 @@ -162,18 +163,18 @@ collection: rebuildPath: true - name: External meta: - id: fld_e7ca6945525a4e48aef91dd05982ab8e + id: fld_1ed0be024ef849a083c6a199aeb570ff created: 1702091446572 modified: 1702091446572 sortKey: -1702091446572 description: "" children: - - url: http://127.0.0.1:1337/api/v2/character/7e094142-841d-48b3-b329-633c2707a593 + - url: 127.0.0.1:1337/api/v2/character/99d08d19-e67f-48f3-beb5-a424de24270e name: Get Character By ID meta: - id: req_7fc6321551ee4ab69d8b84ad107051fc + id: req_8acaacdd253546d58c45e5ba5bef0d88 created: 1701720160986 - modified: 1750892516657 + modified: 1772005038219 isPrivate: false description: "" sortKey: -1702091526359 @@ -189,12 +190,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/character/1337 + - url: 127.0.0.1:1337/api/v2/character/76561197970271912 name: Get Characters meta: - id: req_4dec80d0d35c4d73ae3dcdc18077cf8e + id: req_e76e2ec9e28e48dd904a7b48472e05bc created: 1702086161941 - modified: 1750806429298 + modified: 1771886210189 isPrivate: false description: "" sortKey: -1702091526459 @@ -210,12 +211,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/character/deleted/1337 + - url: 127.0.0.1:1337/api/v2/character/deleted/76561197970271912 name: Get Deleted Characters meta: - id: req_689e4ef6684544de881dd8cbf812bad0 + id: req_b713fe47f409407f96d71ea40ea3ac72 created: 1702087289073 - modified: 1706317029283 + modified: 1771890691966 isPrivate: false description: "" sortKey: -1702091526559 @@ -231,12 +232,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/character/lookup/21743647643/1 + - url: http://127.0.0.1:1337/api/v2/character/lookup/76561197970271912/2 name: Lookup Character ID meta: - id: req_a34d6bfabace4bdf8f5a1aaff22a5463 + id: req_e5b0f88835cc47d2bdff6f4ab911a27b created: 1702092100803 - modified: 1702092128443 + modified: 1771885768138 isPrivate: false description: "" sortKey: -1702091526659 @@ -255,7 +256,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/unsafe/character/move/c5077373-ea54-4f37-a47d-5ab5dc140ff2/to/1234/2 name: Move Character meta: - id: req_6c1c5b72762845e4a90938705f8c8cc1 + id: req_54922995ef274d5a91dcca95fb2a0b9a created: 1702096471985 modified: 1706398471300 isPrivate: false @@ -276,7 +277,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/unsafe/character/copy/3a362194-5783-487c-a76d-49d2c2652d21/to/1337/2 name: Copy Character meta: - id: req_17255148af42494fbe6371dcef3e1472 + id: req_990e2daa835f44b184e500af526b4719 created: 1702096766384 modified: 1706398421538 isPrivate: false @@ -297,7 +298,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/character/restore/f69ffd70-6fdb-443c-a071-7f17565291ad name: Restore Character meta: - id: req_59b939ba33d5460981d6da5c9bcdf48d + id: req_30ba1d0afc43438cb9d832561704dec2 created: 1702165085087 modified: 1750895319533 isPrivate: false @@ -315,31 +316,10 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/character/504a7f9e-f7f3-47dc-9a2d-5b2f04d5fc54 - name: Get Character By ID - meta: - id: req_036feda3bf1f4037905aff008c29b8f9 - created: 1708814692952 - modified: 1727673808306 - isPrivate: false - description: "" - sortKey: -1708814692952 - method: GET - headers: - - name: User-Agent - value: insomnia/8.6.1 - settings: - renderRequestBody: true - encodeUrl: true - followRedirects: global - cookies: - send: true - store: true - rebuildPath: true - url: http://127.0.0.1:1337/api/v2/user/list name: Get All Users meta: - id: req_fff03a9ede964b13aed660c70a89916f + id: req_8173bbe03464471fafb4aa94542009c0 created: 1727569473310 modified: 1727569546238 isPrivate: false @@ -357,12 +337,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/user/admin/1337 + - url: http://127.0.0.1:1337/api/v2/user/admin/76561197970271912 name: Admin Steam ID meta: - id: req_5be4ea11f82f402d8d8ae3799c3bd8f9 + id: req_d49e6fc52b104164b7af4dc846391dea created: 1727729676928 - modified: 1727729784226 + modified: 1772008652772 isPrivate: false description: "" sortKey: -1727729676928 @@ -381,7 +361,7 @@ collection: - url: http://127.0.0.1:1337/api/v2/user/isdonor/1337 name: Is Donor SteamID meta: - id: req_f74a697f4ec84b6d86dcae9ef3d7bb49 + id: req_4852249f62884335a399d8ff36fd8538 created: 1727730248138 modified: 1727730392231 isPrivate: false @@ -399,12 +379,12 @@ collection: send: true store: true rebuildPath: true - - url: http://127.0.0.1:1337/api/v2/user/1337 + - url: http://127.0.0.1:1337/api/v2/user/76561197970271912 name: Get User meta: - id: req_d28bcd23e95e430eba1bd0b1189ebbea + id: req_cc8627bca082466e9884be61e4a4822c created: 1727730684341 - modified: 1727730713639 + modified: 1772004693901 isPrivate: false description: "" sortKey: -1727730684341 @@ -420,20 +400,41 @@ collection: send: true store: true rebuildPath: true + - url: 127.0.0.1:1337/api/v2/character/76561197970271912/2 + name: Get Character By STEAMID and SLOT + meta: + id: req_45c6c0d6fdf74fe1b477ca41f6ae5290 + created: 1772004912440 + modified: 1772004925147 + isPrivate: false + description: "" + sortKey: -1702091396797 + method: GET + headers: + - name: User-Agent + value: insomnia/8.4.5 + settings: + renderRequestBody: true + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true + rebuildPath: true - name: Rollback meta: - id: fld_b07124d8afaf476eba0569c78d52217b + id: fld_256d4ddef2eb42a0a2c9b8967ec1f2c4 created: 1702169927186 modified: 1702169927186 sortKey: -1702169927187 description: "" children: - - url: http://127.0.0.1:1337/api/v2/rollback/character/1ef7ffb5-a353-4fed-884c-49c191a78bdd + - url: 127.0.0.1:1337/api/v2/rollback/character/b07ce88f-fe4b-48fc-8b0d-e23e04d35789 name: Character Versions meta: - id: req_e5f9fccff7d645049438ac8f9af6cc59 + id: req_8ba340a74190489d8d2d3c5268198180 created: 1702169943633 - modified: 1708813512893 + modified: 1772006700822 isPrivate: false description: "" sortKey: -1702169943633 @@ -453,12 +454,12 @@ cookieJar: name: Default Jar meta: id: jar_99d30891da4bdcebc63947a8fc17f076de878684 - created: 1750805916193 - modified: 1750805916193 + created: 1771818355962 + modified: 1771818355962 environments: name: Base Environment meta: id: env_99d30891da4bdcebc63947a8fc17f076de878684 - created: 1750805916130 - modified: 1750805916130 + created: 1771818355961 + modified: 1771818355961 isPrivate: false diff --git a/cmd/app/app.go b/cmd/app/app.go index ba19773..3508db1 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -95,7 +95,7 @@ func (a *App) CalcHashes() error { func (a *App) LoadConfig(path string) (error) { cfg, err := config.Load(path) if err != nil && os.IsNotExist(err) { - fmt.Println("Config file is missing, creating new one!") + fmt.Println("\t Creating New Config File") if err := utils.CopyFile("./runtime/config.example.yaml", path); err != nil { return fmt.Errorf("Unable to create new config %w", err) @@ -144,24 +144,23 @@ func (a *App) InitializeLogger() (err error) { return } -func (a *App) LoadLists() error { +func (a *App) LoadLists() (listErr error) { + fmt.Println("\t Loading System Admins List...") if err := a.loadSystemAdminList(a.Config.ApiAuth.SystemAdmins); err != nil { - return fmt.Errorf("failed to load system admin list: %w", err) + listErr = fmt.Errorf("failed to load system admin list: %w", err) } - if a.Config.ApiAuth.EnforceIP { - if err := a.loadIPList(a.Config.ApiAuth.IPListFile); err != nil { - return fmt.Errorf("failed to load IP whitelist: %w", err) - } + fmt.Println("\t Loading API IP Whitelist List...") + if err := a.loadIPList(a.Config.ApiAuth.IPListFile); err != nil { + listErr = fmt.Errorf("failed to load IP whitelist: %w", err) } - if a.Config.Verify.EnforceMap { - if err := a.loadMapList(a.Config.Verify.MapListFile); err != nil { - return fmt.Errorf("failed to load map list: %w", err) - } + fmt.Println("\t Loading FN Map List...") + if err := a.loadMapList(a.Config.Verify.MapListFile); err != nil { + listErr = fmt.Errorf("failed to load map list: %w", err) } - return nil + return listErr } func (a *App) loadIPList(path string) error { @@ -192,9 +191,8 @@ func (a *App) loadSystemAdminList(path string) error { } func (a *App) Start(mux chi.Router) error { - a.Logger.Info("Starting Nexus2", "App Version", static.Version, "Go Version", static.GoVersion, "OS", static.OS, "Arch", static.OSArch) + defer a.Logger.Info("Starting Nexus2", "App Version", static.Version, "Go Version", static.GoVersion, "OS", static.OS, "Arch", static.OSArch) - a.Logger.Info("Connecting to database") if err := a.DB.Connect(a.Config.Database, database.Options{ Logger: a.SetUpDatabaseLogger(), }); err != nil { @@ -229,11 +227,10 @@ func (a *App) Start(mux chi.Router) error { }, } + fmt.Println("\t Starting HTTP Server...") if a.Config.Cert.Enable { - a.Logger.Info("Starting HTTPS server with cert") return a.StartHTTPWithCert() }else{ - a.Logger.Info("Starting HTTP server") return a.StartHTTP() } diff --git a/cmd/app/db.go b/cmd/app/db.go index 5ae521e..2bf8962 100644 --- a/cmd/app/db.go +++ b/cmd/app/db.go @@ -6,32 +6,30 @@ import ( "log" "io" "os" - "path/filepath" - "github.com/msrevive/nexus2/internal/database/bbolt" - "github.com/msrevive/nexus2/internal/database/badger" + "github.com/msrevive/nexus2/internal/database" "github.com/msrevive/nexus2/internal/database/pebble" + "github.com/msrevive/nexus2/internal/database/sqlite" + "github.com/msrevive/nexus2/internal/database/postgres" "github.com/msrevive/nexus2/pkg/utils" rw "github.com/saintwish/rotatewriter" "github.com/robfig/cron/v3" ) -func (a *App) SetupDatabase() (err error) { +func (a *App) SetupDatabase() error { switch a.Config.Core.DBType { - case "bbolt": - a.Logger.Info("Database set to BBolt!") - // This is needed because BBolt doesn't automatically create the directory. - err = os.MkdirAll(filepath.Dir(a.Config.Database.BBolt.File), os.ModePerm) - a.DB = bbolt.New() - case "badger": - a.Logger.Info("Database set to Badger!") - a.DB = badger.New() case "pebble": a.Logger.Info("Database set to Pebble!") a.DB = pebble.New() + case "sqlite": + a.Logger.Info("Database set to SQLite!") + a.DB = sqlite.New() + case "postgres": + a.Logger.Info("Database set to PostgreSQL!") + a.DB = postgres.New() default: - err = fmt.Errorf("database not available.") + return database.ErrNotAvailable } // Setup database sync @@ -63,15 +61,11 @@ func (a *App) SetupDatabase() (err error) { }) gcCron.Start() - return err + return nil } // TODO: Move this to database package. func (a *App) SetUpDatabaseLogger() *log.Logger { - if a.Config.Core.DBType != "badger" { - return nil - } - if err := os.MkdirAll(a.Config.Log.Dir+"database/", os.ModePerm); err != nil { fmt.Println(fmt.Errorf("database error: failed to create logging directory %v", err)) return nil @@ -90,6 +84,6 @@ func (a *App) SetUpDatabaseLogger() *log.Logger { MaxSize: 5 * rw.Megabyte, }) - fmt.Println("Setting up database logger!") + fmt.Println("\t Setting up database logger...") return log.New(iow, "", log.LstdFlags) } \ No newline at end of file diff --git a/cmd/cmd.go b/cmd/cmd.go index 816282e..356c71c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -63,18 +63,22 @@ func Run(args []string) (error) { ///////////////////////// a := app.New(); + fmt.Printf("-> Loading Config (%s)...\n", flags.cfgFile) if err := a.LoadConfig(flags.cfgFile); err != nil { return err } - + + fmt.Println("-> Initiating Logger...") if err := a.InitializeLogger(); err != nil { return err } + fmt.Println("-> Connecting to Database...") if err := a.SetupDatabase(); err != nil { return err } + fmt.Println("-> Loading Lists...") if err := a.LoadLists(); err != nil { a.Logger.Warn("Failed to load list(s)!", "error", err) } @@ -150,6 +154,7 @@ func Run(args []string) (error) { r.Get("/{uuid}", con.GetCharacterByIDExternal) r.Patch("/restore/{uuid}", con.RestoreCharacter) r.Get("/export/{uuid}", con.ExportCharacter) + r.Get("/{steamid:[0-9]+}/{slot:[0-9]+}", con.GetCharacter) }) r.Route("/rollback/character", func(r chi.Router) { @@ -225,6 +230,7 @@ func Run(args []string) (error) { ///////////////////////// // Application Startup ///////////////////////// + fmt.Println("-> Starting Application...") if err := a.Start(router); err != nil { a.Logger.Error("Failed to start application", "error", err) return err diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..a092087 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,105 @@ +// cmd/migrate/main.go +// +// Usage: +// go run ./cmd/migrate --src pebble --src-dir ./data/pebble \ +// --dst sqlite --dst-path ./data/nexus.db +package main + +import ( + "flag" + "fmt" + "log" + "os" + "io" + "errors" + "time" + + "github.com/msrevive/nexus2/internal/database" + "github.com/msrevive/nexus2/internal/migration" + "github.com/msrevive/nexus2/internal/config" + + // Import whichever backends you have implemented. + nexusPebble "github.com/msrevive/nexus2/internal/database/pebble" + nexusSQLite "github.com/msrevive/nexus2/internal/database/sqlite" + nexusPostgres "github.com/msrevive/nexus2/internal/database/postgres" +) + +func main() { + srcType := flag.String("src", "", "source backend: pebble | sqlite | postgres") + dstType := flag.String("dst", "", "destination backend: pebble | sqlite | postgres") + + flag.Parse() + + // create logger + if _, err := os.Stat("./runtime/migration.log"); !errors.Is(err, os.ErrNotExist) { + os.Remove("./runtime/migration.log") + } + file, err := os.OpenFile("./runtime/migration.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + panic(err) + } + defer file.Close() + + cfg, err := config.Load("./runtime/config.yaml") + if err != nil { + panic(err) + } + + writer := io.MultiWriter(os.Stdout, file) + log.SetOutput(writer) + + if *srcType == "" || *dstType == "" { + log.Fatal("--src and --dst are required") + } + + src, err := openDB(*srcType, cfg.Database) + if err != nil { + log.Fatalf("open source: %v", err) + } + defer src.Disconnect() + + dst, err := openDB(*dstType, cfg.Database) + if err != nil { + log.Fatalf("open destination: %v", err) + } + defer dst.Disconnect() + + // actually start migration now + fmt.Printf("Beginning migration of DB to %s...\n", dstType) + start := time.Now() + + m := migration.New(src, dst) + m.OnProgress = func(steamID string, slot int, charID string) { + log.Printf(" migrated slot %d / char %s for user %s", slot, charID, steamID) + } + + if err := m.Run(); err != nil { + log.Fatalf("migration failed: %v", err) + } + + fmt.Printf("Migration finished, took %v\n", time.Since(start)) + os.Exit(0) +} + +func openDB(kind string, dbcfg database.Config) (database.Database, error) { + var db database.Database + + switch kind { + case "pebble": + db = nexusPebble.New() + + case "sqlite": + db = nexusSQLite.New() + + case "postgres": + db = nexusPostgres.New() + + default: + return nil, fmt.Errorf("unknown backend %q (supported: pebble, sqlite)", kind) + } + + if err := db.Connect(dbcfg, database.Options{}); err != nil { + return nil, fmt.Errorf("connect %s: %w", kind, err) + } + return db, nil +} diff --git a/go.mod b/go.mod index 2ab23d1..1062413 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,26 @@ module github.com/msrevive/nexus2 -go 1.23.0 +go 1.24.0 toolchain go1.24.4 require ( github.com/cockroachdb/pebble/v2 v2.0.7 - github.com/dgraph-io/badger/v4 v4.8.0 github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/httprate v0.15.0 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 github.com/robfig/cron/v3 v3.0.1 github.com/saintwish/kv v1.0.4 github.com/saintwish/rotatewriter v1.0.2 github.com/spf13/pflag v1.0.7 + github.com/stretchr/testify v1.11.1 github.com/sugawarayuuta/sonnet v0.0.0-20231004000330-239c7b6e4ce8 - go.etcd.io/bbolt v1.4.3 golang.org/x/crypto v0.39.0 gopkg.in/ini.v1 v1.67.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.46.1 ) require ( @@ -33,35 +34,40 @@ require ( github.com/cockroachdb/redact v1.1.5 // indirect github.com/cockroachdb/swiss v0.0.0-20250624142022-d6e517c1d961 // indirect github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect - github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect - github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.6 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 1627ebb..0075e06 100644 --- a/go.sum +++ b/go.sum @@ -27,14 +27,9 @@ github.com/cockroachdb/swiss v0.0.0-20250624142022-d6e517c1d961/go.mod h1:yBRu/c github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= -github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= -github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= -github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -49,11 +44,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5 github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -62,12 +52,22 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= -github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -78,8 +78,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -95,6 +99,8 @@ github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -106,8 +112,11 @@ github.com/saintwish/rotatewriter v1.0.2 h1:2nBMi6Z8M00H1siwzQez8Seb5V1b7mcc04zN github.com/saintwish/rotatewriter v1.0.2/go.mod h1:NAqOz4Rn952kQohfH86TIWyBhrJRKaisJJWUf+dlJoQ= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sugawarayuuta/sonnet v0.0.0-20231004000330-239c7b6e4ce8 h1:u+kxnRXxx+0O5SiefP3oTt4jeeIx+rYf1jkdW2qd2Ss= github.com/sugawarayuuta/sonnet v0.0.0-20231004000330-239c7b6e4ce8/go.mod h1:6M53rd6DvbzoLbFnL3bjCsDSkCYh4i2yqW04hxr1/5o= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -118,25 +127,17 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= -golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -147,21 +148,24 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -173,5 +177,34 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/controller/character.go b/internal/controller/character.go index a2095d2..f7d0400 100644 --- a/internal/controller/character.go +++ b/internal/controller/character.go @@ -5,9 +5,11 @@ import ( "net/http" "io" "strconv" + "errors" "github.com/msrevive/nexus2/internal/payload" "github.com/msrevive/nexus2/internal/response" + "github.com/msrevive/nexus2/internal/database" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -89,8 +91,12 @@ func (c *Controller) GetCharacterByIDExternal(w http.ResponseWriter, r *http.Req } char, err := c.service.GetCharacterByID(uid) - if err != nil { - c.logger.Error("service failed", "error", err) + if errors.Is(err, database.ErrNoDocument) { + c.logger.Warn("service warning", "error", err) + response.OKNoContent(w) + return + }else if err != nil { + c.logger.Error("service error", "error", err) response.Error(w, err) return } diff --git a/internal/controller/internal.go b/internal/controller/internal.go index 532ca99..fd50359 100644 --- a/internal/controller/internal.go +++ b/internal/controller/internal.go @@ -5,6 +5,7 @@ import ( "io" "bytes" "strconv" + "errors" "github.com/msrevive/nexus2/internal/database" "github.com/msrevive/nexus2/internal/payload" @@ -92,7 +93,7 @@ func (c *Controller) GetCharacter(w http.ResponseWriter, r *http.Request) { } char, flags, err := c.service.GetCharacter(steamid, slot) - if err == database.ErrNoDocument { + if errors.Is(err, database.ErrNoDocument) { c.logger.Warn("service warning", "error", err) response.OKNoContent(w) return diff --git a/internal/controller/rollback.go b/internal/controller/rollback.go index a689b9c..2572958 100644 --- a/internal/controller/rollback.go +++ b/internal/controller/rollback.go @@ -3,8 +3,10 @@ package controller import ( "net/http" "strconv" + "errors" "github.com/msrevive/nexus2/internal/response" + "github.com/msrevive/nexus2/internal/static" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -20,7 +22,11 @@ func (c *Controller) GetCharacterVersions(w http.ResponseWriter, r *http.Request } data, err := c.service.GetCharacterVersions(uid) - if err != nil { + if errors.Is(err, static.ErrNoCharacterVersions) { + c.logger.Warn("service warning", "error", err) + response.OKNoContent(w) + return + } else if err != nil { c.logger.Error("service failed", "error", err) response.Error(w, err) return diff --git a/internal/database/badger/badger.go b/internal/database/badger/badger.go deleted file mode 100644 index afc501d..0000000 --- a/internal/database/badger/badger.go +++ /dev/null @@ -1,45 +0,0 @@ -package badger - -import ( - "github.com/msrevive/nexus2/internal/database" - - "github.com/dgraph-io/badger/v4" -) - -// The smaller the key prefix the better? -var ( - UserPrefix = []byte("users:") - CharPrefix = []byte("chars:") -) - -type badgerDB struct { - db *badger.DB -} - -func New() *badgerDB { - return &badgerDB{} -} - -func (d *badgerDB) Connect(cfg database.Config, opts database.Options) error { - bOpts := badger.DefaultOptions(cfg.Badger.Directory) - - db, err := badger.Open(bOpts) - if err != nil { - return err - } - - d.db = db - return nil -} - -func (d *badgerDB) Disconnect() error { - return d.db.Close() -} - -func (d *badgerDB) SyncToDisk() error { - return d.db.Sync() -} - -func (d *badgerDB) RunGC() error { - return d.db.RunValueLogGC(0.5) -} diff --git a/internal/database/badger/character.go b/internal/database/badger/character.go deleted file mode 100644 index 7dffb3b..0000000 --- a/internal/database/badger/character.go +++ /dev/null @@ -1,559 +0,0 @@ -package badger - -import ( - "fmt" - "time" - - "github.com/msrevive/nexus2/pkg/database/schema" - "github.com/msrevive/nexus2/internal/database" - - "github.com/google/uuid" - "github.com/dgraph-io/badger/v4" - "github.com/fxamacker/cbor/v2" -) - -func (d *badgerDB) NewCharacter(steamid string, slot int, size int, data string) (uuid.UUID, error) { - charID := uuid.New() - char := schema.Character{ - ID: charID, - SteamID: steamid, - Slot: slot, - CreatedAt: time.Now().UTC(), - Data: schema.CharacterData{ - CreatedAt: time.Now().UTC(), - Size: size, - Data: data, - }, - } - - //use a read-write transaction to save CPU time. - if err := d.db.Update(func(txn *badger.Txn) error { - userKey := append(UserPrefix, []byte(steamid)...) - charKey := append(CharPrefix, []byte(charID.String())...) - - item, err := txn.Get(userKey) - //user doesn't exists so create a new one - if err != nil { - user := &schema.User{ - ID: steamid, - Characters: make(map[int]uuid.UUID), - } - user.Characters[slot] = charID - - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("failed to marshal character %v", err) - } - - //commit new user to DB - if err := txn.Set(userKey, userData); err != nil { - return err - } - - //commit new character to DB - if err := txn.Set(charKey, charData); err != nil { - return err - } - - return nil - }else{ // user does exists so just create character. - data, err := item.ValueCopy(nil) - if err != nil { - return fmt.Errorf("badger: failed to get value from item") - } - - var user *schema.User - if err := cbor.Unmarshal(data, &user); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - user.Characters[slot] = charID - - // we new have to encode the new userdata, this seems like such a waste... - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("failed to marshal character %v", err) - } - - //commit new user to DB - if err := txn.Set(userKey, userData); err != nil { - return fmt.Errorf("badger: failed to set user %v with key %s", err, userKey) - } - - //commit new character to DB - if err := txn.Set(charKey, charData); err != nil { - return fmt.Errorf("badger: failed to set character %v with key %s", err, charKey) - } - - return nil - } - - return nil - }); err != nil { - return uuid.Nil, err - } - - return charID, nil -} - -func (d *badgerDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { - key := append(CharPrefix, []byte(id.String())...) - - if err := d.db.Update(func(txn *badger.Txn) error { - item, err := txn.Get(key) - if err != nil { - return err - } - - val, err := item.ValueCopy(nil) - if err != nil { - return fmt.Errorf("badger: failed to get value from item") - } - - var char *schema.Character - if err := cbor.Unmarshal(val, &char); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - //handle character backups for rollback system. - bCharsLen := len(char.Versions) - if backupMax > 0 { - // we remove the oldest backup here - if bCharsLen >= backupMax { - copy(char.Versions, char.Versions[1:]) - char.Versions = char.Versions[:bCharsLen-1] - bCharsLen-- - } - - if bCharsLen > 0 { - bNewest := char.Versions[bCharsLen-1] //latest backup - - timeCheck := bNewest.CreatedAt.Add(backupTime) - if char.Data.CreatedAt.After(timeCheck) { - char.Versions = append(char.Versions, char.Data) - } - }else{ - char.Versions = append(char.Versions, char.Data) - } - } - - char.Data = schema.CharacterData{ - CreatedAt: time.Now().UTC(), - Size: size, - Data: data, - } - - //commit updated character to DB - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(key, charData); err != nil { - return fmt.Errorf("badger: failed to set character %v with key %s", err, key) - } - - return nil - }); err != nil { - return nil - } - - return nil -} - -func (d *badgerDB) GetCharacter(id uuid.UUID) (char *schema.Character, err error) { - key := append(CharPrefix, []byte(id.String())...) - - if err = d.db.View(func(txn *badger.Txn) error { - item, err := txn.Get(key) - if err == badger.ErrKeyNotFound { - return database.ErrNoDocument - }else if err != nil { - return err - } - - data, err := item.ValueCopy(nil) - if err != nil { - return fmt.Errorf("badger: failed to get value from item") - } - - if err := cbor.Unmarshal(data, &char); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - return nil - }); err != nil { - return - } - - return -} - -func (d *badgerDB) GetCharacters(steamid string) (map[int]schema.Character, error) { - user, err := d.GetUser(steamid) - if err != nil { - return nil, err - } - - chars := make(map[int]schema.Character, len(user.Characters)-1) - for k,v := range user.Characters { - char, err := d.GetCharacter(v) - if err != nil { - return nil, err - } - chars[k] = *char - } - - return chars, nil -} - -func (d *badgerDB) LookUpCharacterID(steamid string, slot int) (uuid.UUID, error) { - user, err := d.GetUser(steamid) - if err != nil { - return uuid.Nil, err - } - - uuid := user.Characters[slot] - return uuid, nil -} - -// We remove the character from user's active list and set an expiration. -func (d *badgerDB) SoftDeleteCharacter(id uuid.UUID, expiration time.Duration) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - user, err := d.GetUser(char.SteamID) - if err != nil { - return err - } - - delete(user.Characters, char.Slot) - user.DeletedCharacters = make(map[int]uuid.UUID, 1) - user.DeletedCharacters[char.Slot] = id - - timeNow := time.Now().UTC() - char.DeletedAt = &timeNow - - userKey := append(UserPrefix, []byte(char.SteamID)...) - charKey := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(userKey, userData); err != nil { - return fmt.Errorf("badger: failed to update user %v", err) - } - - charEntry := badger.NewEntry(charKey, charData) - charEntry.WithTTL(expiration) - if err := txn.SetEntry(charEntry); err != nil { - return fmt.Errorf("badger: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) DeleteCharacter(id uuid.UUID) error { - key := append(CharPrefix, []byte(id.String())...) - if err := d.db.Update(func(txn *badger.Txn) error { - if err := txn.Delete(key); err != nil { - return fmt.Errorf("badger: failed to delete character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) DeleteCharacterReference(steamid string, slot int) error { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - delete(user.Characters, slot) - - key := append(UserPrefix, []byte(steamid)...) - if err = d.db.Update(func(txn *badger.Txn) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - if err := txn.Set(key, userData); err != nil { - return fmt.Errorf("badger: failed to update user %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) MoveCharacter(id uuid.UUID, steamid string, slot int) error { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - // Delete reference to the character from via old user - if err := d.DeleteCharacterReference(char.SteamID, char.Slot); err != nil { - return err - } - - // Assign character ID to the new account. - user.Characters[slot] = id - // Update the character information with new account data. - char.SteamID = steamid - char.Slot = slot - - userKey := append(UserPrefix, []byte(steamid)...) - charKey := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(userKey, userData); err != nil { - return fmt.Errorf("badger: failed to update user %v", err) - } - - if err := txn.Set(charKey, charData); err != nil { - return fmt.Errorf("badger: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) CopyCharacter(id uuid.UUID, steamid string, slot int) (uuid.UUID, error) { - // Create reference to "new" character. - targetUser, err := d.GetUser(steamid) - if err != nil { - return uuid.Nil, err - } - - // Insert new character data. - char, err := d.GetCharacter(id) - if err != nil { - return uuid.Nil, err - } - - charID := uuid.New() - targetUser.Characters[slot] = charID - - char.ID = charID - char.SteamID = steamid - char.Slot = slot - char.CreatedAt = time.Now().UTC() - - userKey := append(UserPrefix, []byte(steamid)...) - charKey := append(CharPrefix, []byte(charID.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - userData, err := cbor.Marshal(&targetUser) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(userKey, userData); err != nil { - return fmt.Errorf("badger: failed to update user %v", err) - } - - if err := txn.Set(charKey, charData); err != nil { - return fmt.Errorf("badger: failed to update character %v", err) - } - - return nil - }); err != nil { - return uuid.Nil, err - } - - return charID, nil -} - -func (d *badgerDB) RestoreCharacter(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - user, err := d.GetUser(char.SteamID) - if err != nil { - return err - } - - user.Characters[char.Slot] = id - delete(user.DeletedCharacters, char.Slot) - - char.DeletedAt = nil - - userKey := append(UserPrefix, []byte(char.SteamID)...) - charKey := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(userKey, userData); err != nil { - return fmt.Errorf("badger: failed to update user %v", err) - } - - if err := txn.Set(charKey, charData); err != nil { - return fmt.Errorf("badger: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) RollbackCharacter(id uuid.UUID, ver int) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - bCharsLen := len(char.Versions) - if bCharsLen > ver { - // Replace the active character with the selected version - char.Data = char.Versions[ver] - }else{ - return fmt.Errorf("no character version at index %d", ver) - } - - key := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(key, charData); err != nil { - return fmt.Errorf("badger: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) RollbackCharacterToLatest(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - bCharsLen := len(char.Versions) - if bCharsLen > 0 { - // Replace the active character with the selected version - char.Data = char.Versions[bCharsLen-1] - }else{ - return fmt.Errorf("no character backups exist") - } - - key := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := txn.Set(key, charData); err != nil { - return fmt.Errorf("badger: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) DeleteCharacterVersions(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - char.Versions = nil - - key := append(CharPrefix, []byte(id.String())...) - if err = d.db.Update(func(txn *badger.Txn) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode char %v", err) - } - - if err := txn.Set(key, charData); err != nil { - return fmt.Errorf("badger: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} \ No newline at end of file diff --git a/internal/database/badger/user.go b/internal/database/badger/user.go deleted file mode 100644 index df59fad..0000000 --- a/internal/database/badger/user.go +++ /dev/null @@ -1,103 +0,0 @@ -package badger - -import ( - "fmt" - - "github.com/msrevive/nexus2/internal/bitmask" - "github.com/msrevive/nexus2/internal/database" - "github.com/msrevive/nexus2/pkg/database/schema" - - "github.com/dgraph-io/badger/v4" - "github.com/fxamacker/cbor/v2" -) - -func (d *badgerDB) GetAllUsers() ([]*schema.User, error) { - var users []*schema.User - - if err := d.db.View(func(txn *badger.Txn) error { - it := txn.NewIterator(badger.DefaultIteratorOptions) - defer it.Close() - - - for it.Seek(UserPrefix); it.ValidForPrefix(UserPrefix); it.Next() { - item := it.Item() - - item.Value(func(v []byte) error { - var user *schema.User - if err := cbor.Unmarshal(v, &user); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - users = append(users, user) - return nil - }) - } - - return nil - }); err != nil { - return nil, err - } - - return users, nil -} - -func (d *badgerDB) GetUser(steamid string) (user *schema.User, err error) { - if err = d.db.View(func(txn *badger.Txn) error { - // we attack a prefix to the key. we are treating prefixes like buckets. - key := append(UserPrefix, []byte(steamid)...) - - item, err := txn.Get(key) - if err == badger.ErrKeyNotFound { - return database.ErrNoDocument - }else if err != nil { - return err - } - - data, err := item.ValueCopy(nil) - if err != nil { - return fmt.Errorf("badger: failed to get value from item") - } - - if err := cbor.Unmarshal(data, &user); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - return nil - }); err != nil { - return - } - - return -} - -func (d *badgerDB) SetUserFlags(steamid string, flags bitmask.Bitmask) (error) { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - if err = d.db.Update(func(txn *badger.Txn) error { - user.Flags = uint32(flags) // cast it to a uint32 to make the database behave. - - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - key := append(UserPrefix, []byte(steamid)...) - return txn.Set(key, userData) - }); err != nil { - return err - } - - return nil -} - -func (d *badgerDB) GetUserFlags(steamid string) (bitmask.Bitmask, error) { - user, err := d.GetUser(steamid) - if err != nil { - return 0, err - } - - return bitmask.Bitmask(user.Flags), nil -} \ No newline at end of file diff --git a/internal/database/bbolt/bbolt.go b/internal/database/bbolt/bbolt.go deleted file mode 100644 index 5dcba8d..0000000 --- a/internal/database/bbolt/bbolt.go +++ /dev/null @@ -1,62 +0,0 @@ -package bbolt - -import ( - "fmt" - "time" - - "github.com/msrevive/nexus2/internal/database" - - "go.etcd.io/bbolt" -) - -var ( - UserBucket = []byte("users") - CharBucket = []byte("chars") -) - -type bboltDB struct { - db *bbolt.DB -} - -func New() *bboltDB { - return &bboltDB{} -} - -func (d *bboltDB) Connect(cfg database.Config, opts database.Options) error { - timeout := time.Duration(cfg.BBolt.Timeout) * time.Second - db, err := bbolt.Open(cfg.BBolt.File, 0755, &bbolt.Options{Timeout: timeout}) - if err != nil { - return err - } - - if err := db.Update(func(tx *bbolt.Tx) error { - _, err = tx.CreateBucketIfNotExists(UserBucket) - if err != nil { - return fmt.Errorf("failed to create users bucket: %s", err) - } - - _, err = tx.CreateBucketIfNotExists(CharBucket) - if err != nil { - return fmt.Errorf("failed to create characters bucket: %s", err) - } - - return nil - }); err != nil { - return err - } - - d.db = db - return nil -} - -func (d *bboltDB) Disconnect() error { - return d.db.Close() -} - -func (d *bboltDB) SyncToDisk() error { - return nil -} - -func (d *bboltDB) RunGC() error { - return nil -} \ No newline at end of file diff --git a/internal/database/bbolt/character.go b/internal/database/bbolt/character.go deleted file mode 100644 index b651b5e..0000000 --- a/internal/database/bbolt/character.go +++ /dev/null @@ -1,547 +0,0 @@ -package bbolt - -import ( - "fmt" - "time" - - "github.com/msrevive/nexus2/internal/database" - "github.com/msrevive/nexus2/pkg/database/schema" - - "github.com/google/uuid" - "go.etcd.io/bbolt" - "github.com/fxamacker/cbor/v2" -) - -func (d *bboltDB) NewCharacter(steamid string, slot int, size int, data string) (uuid.UUID, error) { - charID := uuid.New() - char := schema.Character{ - ID: charID, - SteamID: steamid, - Slot: slot, - CreatedAt: time.Now().UTC(), - Data: schema.CharacterData{ - CreatedAt: time.Now().UTC(), - Size: size, - Data: data, - }, - } - - user, err := d.GetUser(steamid) - if err == database.ErrNoDocument { - if err = d.db.Update(func(tx *bbolt.Tx) error { - user = &schema.User{ - ID: steamid, - Characters: make(map[int]uuid.UUID), - } - user.Characters[slot] = charID - - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("failed to marshal character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to put in users", err) - } - - if err := bChar.Put([]byte(charID.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to put in characters", err) - } - - return nil - }); err != nil { - return uuid.Nil, err - } - }else if err != nil { - return uuid.Nil, err - } - - if user != nil { - if err = d.db.Update(func(tx *bbolt.Tx) error { - user.Characters[slot] = charID - - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("failed to marshal character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to put in users", err) - } - - if err := bChar.Put([]byte(charID.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to put in characters", err) - } - - return nil - }); err != nil { - return uuid.Nil, err - } - } - - return charID, nil -} - -func (d *bboltDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { - if err := d.db.Batch(func(tx *bbolt.Tx) error { - b := tx.Bucket(CharBucket) - - // Get the character data. - item := b.Get([]byte(id.String())) - if len(item) == 0 { - return database.ErrNoDocument - } - - // Decode the data - var char *schema.Character - if err := cbor.Unmarshal(item, &char); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - // Handle backups for characters - bCharsLen := len(char.Versions) - if backupMax > 0 { - // we remove the oldest backup here - if bCharsLen >= backupMax { - copy(char.Versions, char.Versions[1:]) - char.Versions = char.Versions[:bCharsLen-1] - bCharsLen-- - } - - if bCharsLen > 0 { - bNewest := char.Versions[bCharsLen-1] //latest backup - - timeCheck := bNewest.CreatedAt.Add(backupTime) - if char.Data.CreatedAt.After(timeCheck) { - char.Versions = append(char.Versions, char.Data) - } - }else{ - char.Versions = append(char.Versions, char.Data) - } - } - - char.Data = schema.CharacterData{ - CreatedAt: time.Now().UTC(), - Size: size, - Data: data, - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - if err := b.Put([]byte(char.ID.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) GetCharacter(id uuid.UUID) (char *schema.Character, err error) { - if err = d.db.View(func(tx *bbolt.Tx) error { - b := tx.Bucket(CharBucket) - - data := b.Get([]byte(id.String())) - if len(data) == 0 { - return database.ErrNoDocument - } - - if err := cbor.Unmarshal(data, &char); err != nil { - return fmt.Errorf("bson: failed to decode %v", err) - } - - return nil - }); err != nil { - return - } - - return -} - -func (d *bboltDB) GetCharacters(steamid string) (map[int]schema.Character, error) { - user, err := d.GetUser(steamid) - if err != nil { - return nil, err - } - - chars := make(map[int]schema.Character, len(user.Characters)-1) - for k,v := range user.Characters { - char, err := d.GetCharacter(v) - if err != nil { - return nil, err - } - chars[k] = *char - } - - return chars, nil -} - -func (d *bboltDB) LookUpCharacterID(steamid string, slot int) (uuid.UUID, error) { - user, err := d.GetUser(steamid) - if err != nil { - return uuid.Nil, err - } - - uuid := user.Characters[slot] - return uuid, nil -} - -func (d *bboltDB) SoftDeleteCharacter(id uuid.UUID, expiration time.Duration) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - user, err := d.GetUser(char.SteamID) - if err != nil { - return err - } - - delete(user.Characters, char.Slot) - user.DeletedCharacters = make(map[int]uuid.UUID, 1) - user.DeletedCharacters[char.Slot] = id - - timeNow := time.Now().UTC() - char.DeletedAt = &timeNow - - if err = d.db.Update(func(tx *bbolt.Tx) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(char.SteamID), userData); err != nil { - return fmt.Errorf("bbolt: failed to update user %v", err) - } - - if err := bChar.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) DeleteCharacter(id uuid.UUID) error { - if err := d.db.Update(func(tx *bbolt.Tx) error { - b := tx.Bucket(CharBucket) - - if err := b.Delete([]byte(id.String())); err != nil { - return fmt.Errorf("bbolt: failed to delete character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) DeleteCharacterReference(steamid string, slot int) error { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - delete(user.Characters, slot) - - if err = d.db.Update(func(tx *bbolt.Tx) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - b := tx.Bucket(UserBucket) - - if err := b.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to update user %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) MoveCharacter(id uuid.UUID, steamid string, slot int) error { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - // Delete reference to the character from via old user - if err := d.DeleteCharacterReference(char.SteamID, char.Slot); err != nil { - return err - } - - // Assign character ID to the new account. - user.Characters[slot] = id - - // Update the character information with new account data. - char.SteamID = steamid - char.Slot = slot - - if err = d.db.Update(func(tx *bbolt.Tx) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to update user %v", err) - } - - if err := bChar.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) CopyCharacter(id uuid.UUID, steamid string, slot int) (uuid.UUID, error) { - // Create reference to "new" character. - targetUser, err := d.GetUser(steamid) - if err != nil { - return uuid.Nil, err - } - - // Insert new character data. - char, err := d.GetCharacter(id) - if err != nil { - return uuid.Nil, err - } - - charID := uuid.New() - targetUser.Characters[slot] = charID - - char.ID = charID - char.SteamID = steamid - char.Slot = slot - char.CreatedAt = time.Now().UTC() - - if err = d.db.Update(func(tx *bbolt.Tx) error { - userData, err := cbor.Marshal(&targetUser) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to update user %v", err) - } - - if err := bChar.Put([]byte(charID.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update character %v", err) - } - - return nil - }); err != nil { - return uuid.Nil, err - } - - return charID, nil -} - -func (d *bboltDB) RestoreCharacter(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - user, err := d.GetUser(char.SteamID) - if err != nil { - return err - } - - user.Characters[char.Slot] = id - delete(user.DeletedCharacters, char.Slot) - - char.DeletedAt = nil - - if err = d.db.Update(func(tx *bbolt.Tx) error { - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) - } - - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - bUser := tx.Bucket(UserBucket) - bChar := tx.Bucket(CharBucket) - - if err := bUser.Put([]byte(char.SteamID), userData); err != nil { - return fmt.Errorf("bbolt: failed to update user %v", err) - } - - if err := bChar.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update character %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) RollbackCharacter(id uuid.UUID, ver int) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - bCharsLen := len(char.Versions) - if bCharsLen > ver { - // Replace the active character with the selected version - char.Data = char.Versions[ver] - }else{ - return fmt.Errorf("no character version at index %d", ver) - } - - if err = d.db.Update(func(tx *bbolt.Tx) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - b := tx.Bucket(CharBucket) - - if err := b.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) RollbackCharacterToLatest(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - bCharsLen := len(char.Versions) - if bCharsLen > 0 { - // Replace the active character with the selected version - char.Data = char.Versions[bCharsLen-1] - }else{ - return fmt.Errorf("no character backups exist") - } - - if err = d.db.Update(func(tx *bbolt.Tx) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) - } - - b := tx.Bucket(CharBucket) - - if err := b.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) DeleteCharacterVersions(id uuid.UUID) error { - char, err := d.GetCharacter(id) - if err != nil { - return err - } - - char.Versions = nil - - if err = d.db.Update(func(tx *bbolt.Tx) error { - charData, err := cbor.Marshal(&char) - if err != nil { - return fmt.Errorf("bson: failed to encode char %v", err) - } - - b := tx.Bucket(CharBucket) - - if err := b.Put([]byte(id.String()), charData); err != nil { - return fmt.Errorf("bbolt: failed to update char %v", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} \ No newline at end of file diff --git a/internal/database/bbolt/user.go b/internal/database/bbolt/user.go deleted file mode 100644 index c136f0e..0000000 --- a/internal/database/bbolt/user.go +++ /dev/null @@ -1,98 +0,0 @@ -package bbolt - -import ( - "fmt" - - "github.com/msrevive/nexus2/internal/bitmask" - "github.com/msrevive/nexus2/internal/database" - "github.com/msrevive/nexus2/pkg/database/schema" - - "go.etcd.io/bbolt" - "github.com/fxamacker/cbor/v2" -) - -func (d *bboltDB) GetAllUsers() ([]*schema.User, error) { - var users []*schema.User - - if err := d.db.View(func(tx *bbolt.Tx) error { - b := tx.Bucket(UserBucket) - - if err := b.ForEach(func(k, v []byte) error { - var user *schema.User - - if err := cbor.Unmarshal(v, &user); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - users = append(users, user) - - return nil - }); err != nil { - return err - } - - return nil - }); err != nil { - return users, nil - } - - return users, nil -} - -func (d *bboltDB) GetUser(steamid string) (user *schema.User, err error) { - if err = d.db.View(func(tx *bbolt.Tx) error { - b := tx.Bucket(UserBucket) - - data := b.Get([]byte(steamid)) - if len(data) == 0 { - return database.ErrNoDocument - } - - if err := cbor.Unmarshal(data, &user); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) - } - - return nil - }); err != nil { - return - } - - return -} - -func (d *bboltDB) SetUserFlags(steamid string, flags bitmask.Bitmask) (error) { - user, err := d.GetUser(steamid) - if err != nil { - return err - } - - if err = d.db.Update(func(tx *bbolt.Tx) error { - user.Flags = uint32(flags) // cast it to a uint32 to make the database behave. - - userData, err := cbor.Marshal(&user) - if err != nil { - return fmt.Errorf("failed to marshal user %v", err) - } - - bUser := tx.Bucket(UserBucket) - - if err := bUser.Put([]byte(steamid), userData); err != nil { - return fmt.Errorf("bbolt: failed to put in users", err) - } - - return nil - }); err != nil { - return err - } - - return nil -} - -func (d *bboltDB) GetUserFlags(steamid string) (bitmask.Bitmask, error) { - user, err := d.GetUser(steamid) - if err != nil { - return 0, err - } - - return bitmask.Bitmask(user.Flags), nil -} \ No newline at end of file diff --git a/internal/database/config.go b/internal/database/config.go index 11ebde4..37e7c43 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -4,19 +4,17 @@ import ( ) type Config struct { - MongoDB struct { - Connection string - } - BBolt struct { - File string - Timeout int - } - Badger struct { - Directory string - } Pebble struct { Directory string } + SQLite struct { + Path string + } + Postgres struct { + Conn string + MinConns int32 + MaxConns int32 + } Sync string GarbageCollection string } \ No newline at end of file diff --git a/internal/database/database.go b/internal/database/database.go index 09626e5..790f8fc 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -13,6 +13,8 @@ import ( var ( ErrNoDocument = errors.New("no document") + ErrNotImplemented = errors.New("database not yet implemented") + ErrNotAvailable = errors.New("database not available") ) type Options struct { diff --git a/internal/database/pebble/character.go b/internal/database/pebble/character.go index a9cd407..5fb6977 100644 --- a/internal/database/pebble/character.go +++ b/internal/database/pebble/character.go @@ -102,65 +102,86 @@ func (d *pebbleDB) NewCharacter(steamid string, slot int, size int, data string) return charID, nil } -func (d *pebbleDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { - key := append(CharPrefix, []byte(id.String())...) - - val, io, err := d.db.Get(key) +// applyCharacterUpdate performs the read-modify-write for one character and +// writes the result into the provided Batch. Because the Batch is committed +// atomically by the writer goroutine, the updated character and any new +// version entry land together or not at all. +func (d *pebbleDB) applyCharacterUpdate(b *pebble.Batch, charIDStr string, upd pendingUpdate) error { + key := append(CharPrefix, []byte(charIDStr)...) + + // Read the current character state directly from Pebble (not the batch — + // we need the persisted state to decide whether to snapshot a version). + val, closer, err := d.db.Get(key) if err == pebble.ErrNotFound { return database.ErrNoDocument - }else if err != nil { + } + if err != nil { return err } - - defer io.Close() + defer closer.Close() var char *schema.Character if err := cbor.Unmarshal(val, &char); err != nil { - return fmt.Errorf("failed to unmarshal %v", err) + return fmt.Errorf("unmarshal character: %w", err) } - //handle character backups for rollback system. bCharsLen := len(char.Versions) - if backupMax > 0 { - // we remove the oldest backup here - if bCharsLen >= backupMax { + if upd.backupMax > 0 { + if bCharsLen >= upd.backupMax { copy(char.Versions, char.Versions[1:]) char.Versions = char.Versions[:bCharsLen-1] bCharsLen-- } if bCharsLen > 0 { - bNewest := char.Versions[bCharsLen-1] //latest backup - - timeCheck := bNewest.CreatedAt.Add(backupTime) - if char.Data.CreatedAt.After(timeCheck) { + newest := char.Versions[bCharsLen-1] + if char.Data.CreatedAt.After(newest.CreatedAt.Add(upd.backupTime)) { char.Versions = append(char.Versions, char.Data) } - }else{ + } else { char.Versions = append(char.Versions, char.Data) } } char.Data = schema.CharacterData{ - CreatedAt: time.Now().UTC(), - Size: size, - Data: data, + CreatedAt: time.Now().UTC(), + Size: upd.size, + Data: upd.data, } - //commit updated character to DB charData, err := cbor.Marshal(&char) if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) + return fmt.Errorf("marshal character: %w", err) } - return d.db.Set(key, charData, pebble.NoSync) + // Write into the batch — not directly to d.db. + // The caller (writeWorker) commits the whole batch atomically. + return b.Set(key, charData, nil) +} + +// UpdateCharacter enqueues the update in the coalescing map and returns +// immediately. The flushWorker will apply it (along with any other updates +// that arrived before the next tick) in a single Batch commit. +// +// This means 100 calls for the same character between ticks = 1 Pebble Set. +// All characters updated in a single flush window = 1 batch commit = 1 WAL append. +func (d *pebbleDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { + d.coalesceMu.Lock() + d.pendingUpdates[id.String()] = pendingUpdate{ + size: size, + data: data, + backupMax: backupMax, + backupTime: backupTime, + } + d.coalesceMu.Unlock() + return nil } func (d *pebbleDB) GetCharacter(id uuid.UUID) (*schema.Character, error) { var char *schema.Character = nil key := append(CharPrefix, []byte(id.String())...) - data, io, err := d.db.Get(key) + data, io, err := d.get(key) if err == pebble.ErrNotFound { return char, database.ErrNoDocument }else if err != nil { @@ -226,12 +247,12 @@ func (d *pebbleDB) SoftDeleteCharacter(id uuid.UUID, expiration time.Duration) e userData, err := cbor.Marshal(&user) if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) + return fmt.Errorf("cbor: failed to encode user %v", err) } charData, err := cbor.Marshal(&char) if err != nil { - return fmt.Errorf("bson: failed to encode character %v", err) + return fmt.Errorf("cbor: failed to encode character %v", err) } if err := d.db.Set(userKey, userData, pebble.NoSync); err != nil { @@ -261,7 +282,7 @@ func (d *pebbleDB) DeleteCharacterReference(steamid string, slot int) error { userData, err := cbor.Marshal(&user) if err != nil { - return fmt.Errorf("bson: failed to encode user %v", err) + return fmt.Errorf("cbor: failed to encode user %v", err) } if err := d.db.Set(key, userData, pebble.Sync); err != nil { diff --git a/internal/database/pebble/pebble.go b/internal/database/pebble/pebble.go index ef8afff..37d571e 100644 --- a/internal/database/pebble/pebble.go +++ b/internal/database/pebble/pebble.go @@ -6,10 +6,12 @@ import ( "fmt" "context" "encoding/binary" + "sync" "github.com/msrevive/nexus2/internal/database" "github.com/cockroachdb/pebble/v2" + "github.com/cockroachdb/pebble/v2/vfs" ) // The smaller the key prefix the better? @@ -19,33 +21,85 @@ var ( CharPrefix = []byte("chars:") ) +// pendingUpdate holds the latest coalesced state for a character. +// Only the most recent call to UpdateCharacter per character ID between +// flush ticks is kept — intermediate states are discarded. +type pendingUpdate struct { + size int + data string + backupMax int + backupTime time.Duration +} + +// writeOp is a unit of work for the single-writer goroutine. +// fn receives an empty Batch; when fn returns nil the batch is committed. +type writeOp struct { + fn func(b *pebble.Batch) error + resp chan error +} + type pebbleDB struct { db *pebble.DB - batch *pebble.Batch + + // writeCh serializes all mutations through a single goroutine so + // Pebble's batch commits are never raced. + writeCh chan writeOp + + // flushInterval controls how often the coalescing buffer is drained. + flushInterval time.Duration + + // coalesceMu protects pendingUpdates. Only UpdateCharacter writes to it; + // the flush goroutine swaps it out under the lock. + coalesceMu sync.Mutex + pendingUpdates map[string]pendingUpdate // key = char UUID string + + done chan struct{} + wg sync.WaitGroup + + database.Options } func New() *pebbleDB { - return &pebbleDB{} + return &pebbleDB{ + writeCh: make(chan writeOp, 512), + flushInterval: 500 * time.Millisecond, + pendingUpdates: make(map[string]pendingUpdate), + done: make(chan struct{}), + } } func (d *pebbleDB) Connect(cfg database.Config, opts database.Options) error { - db, err := pebble.Open(cfg.Pebble.Directory, &pebble.Options{ - FormatMajorVersion: pebble.FormatColumnarBlocks, - }) + var db *pebble.DB + var err error + + if cfg.Pebble.Directory == "" { + db, err = pebble.Open("", &pebble.Options{ + FormatMajorVersion: pebble.FormatColumnarBlocks, + FS: vfs.NewMem(), + }) + } else { + db, err = pebble.Open(cfg.Pebble.Directory, &pebble.Options{ + FormatMajorVersion: pebble.FormatColumnarBlocks, + }) + } + if err != nil { return err } - d.batch = db.NewBatch() + d.wg.Add(2) + go d.writeWorker() + go d.flushWorker() d.db = db + d.Logger = opts.Logger return nil } func (d *pebbleDB) Disconnect() (err error) { - err = d.batch.Close() - err = d.db.Close() - return + close(d.done) + d.wg.Wait() + return d.db.Close() } func (d *pebbleDB) SyncToDisk() error { @@ -56,36 +110,34 @@ func (d *pebbleDB) SyncToDisk() error { } func (d *pebbleDB) RunGC() error { - // better to use contexts https://github.com/cockroachdb/pebble/issues/1609 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - it, err := d.db.NewIterWithContext(ctx, nil) - if err != nil { + // Flush pending updates before iterating so nothing stale is left behind. + if err := d.flushPendingUpdates(); err != nil { return err } - defer it.Close() - if d.batch.Count() > 0 { - if err := d.batch.Commit(pebble.Sync); err != nil { + return d.exec(func(b *pebble.Batch) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + it, err := d.db.NewIterWithContext(ctx, nil) + if err != nil { return err } - } - d.batch.Reset() - - for it.First(); it.Valid(); it.Next() { - value := it.Value() - exTime := int64(binary.BigEndian.Uint64(value[:timestampSize])) // unix is int64 so this needs to be int64 + defer it.Close() - if (len(value) >= timestampSize) && (exTime > 0) { - if time.Now().Unix() > exTime { - if err := d.batch.Delete(it.Key(), nil); err != nil { - return err + for it.First(); it.Valid(); it.Next() { + value := it.Value() + if len(value) > 0 && value[0] == ttlMagic && len(value) >= 1+timestampSize { + exTime := int64(binary.BigEndian.Uint64(value[1 : 1+timestampSize])) + if time.Now().Unix() > exTime { + if err := b.Delete(it.Key(), nil); err != nil { + return err + } } } } - } - - return nil + return nil + }) } /* @@ -93,33 +145,52 @@ func (d *pebbleDB) RunGC() error { - functions to implement our own TTL implementation and prefix handling for iterators. */ //8 bytes for a Unix timestamp, this is cause in 2032 8 bytes will be needed to store unix timestamp -const timestampSize = 8 +const ( + timestampSize = 8 + ttlMagic = byte(0xFF) // marker to indicate this value has a TTL prefix +) func (d *pebbleDB) setWithTTL(key, value []byte, ttl time.Duration, opts *pebble.WriteOptions) error { - expiration := time.Now().Add(ttl).Unix() - fmt.Println(uint64(expiration)) - buf := make([]byte, timestampSize+len(value)) //8 bytes for a Unix timestamp - binary.BigEndian.PutUint64(buf, uint64(expiration)) - copy(buf[timestampSize:], value) + expiration := time.Now().Add(ttl).Unix() + // layout: [0xFF magic][8 bytes expiry][value] + buf := make([]byte, 1+timestampSize+len(value)) + buf[0] = ttlMagic + binary.BigEndian.PutUint64(buf[1:], uint64(expiration)) + copy(buf[1+timestampSize:], value) - return d.db.Set(key, buf, opts) + return d.db.Set(key, buf, opts) } func (d *pebbleDB) get(key []byte) ([]byte, io.Closer, error) { - value, closer, err := d.db.Get(key) - exTime := binary.BigEndian.Uint64(value[:timestampSize]) - if err != nil { - return nil, nil, err - } + value, closer, err := d.db.Get(key) + if err != nil { + return nil, nil, err + } + defer closer.Close() - // if there's no timestamp then just return. - if (len(value) < timestampSize) || (exTime == 0) { - return value, closer, nil - } + // no TTL prefix, return as-is + if len(value) == 0 || value[0] != ttlMagic { + out := make([]byte, len(value)) + copy(out, value) + return out, closer, nil + } - // we don't check if the entry is expired because we don't need to for this. - // we need to get everything after the first timestamp size. - return value[timestampSize:], closer, nil + // has TTL prefix + if len(value) < 1+timestampSize { + return nil, nil, fmt.Errorf("corrupt value for key") + } + + exTime := int64(binary.BigEndian.Uint64(value[1 : 1+timestampSize])) + if time.Now().Unix() > exTime { + closer.Close() + // expired - delete it and return not found + _ = d.db.Delete(key, pebble.NoSync) + return nil, nil, pebble.ErrNotFound + } + + payload := make([]byte, len(value)-1-timestampSize) + copy(payload, value[1+timestampSize:]) + return payload, closer, nil } // keyUpperBound returns the smallest key that is lexicographically greater than the given prefix. @@ -134,4 +205,95 @@ func keyUpperBound(b []byte) []byte { } } return nil // The prefix is all 0xFF bytes, which is the end of the key space. +} + +// exec sends fn to the single-writer goroutine and blocks until it completes. +// fn receives a fresh Batch; the writer commits it after fn returns nil. +func (d *pebbleDB) exec(fn func(b *pebble.Batch) error) error { + resp := make(chan error, 1) + d.writeCh <- writeOp{fn: fn, resp: resp} + return <-resp +} + +// writeWorker is the only goroutine that commits batches to Pebble. +// Centralizing writes here means batches are never committed concurrently, +// which avoids contention on Pebble's commit pipeline. +func (d *pebbleDB) writeWorker() { + defer d.wg.Done() + + runOp := func(op writeOp) { + b := d.db.NewBatch() + if err := op.fn(b); err != nil { + _ = b.Close() + op.resp <- err + return + } + + op.resp <- b.Commit(pebble.NoSync) + } + + for { + select { + case op := <-d.writeCh: + runOp(op) + case <-d.done: + for { + select { + case op := <-d.writeCh: + runOp(op) + default: + return + } + } + } + } +} + +// flushWorker ticks on flushInterval and drains the coalescing buffer. +// On shutdown it performs one last flush so no pending updates are dropped. +func (d *pebbleDB) flushWorker() error { + defer d.wg.Done() + ticker := time.NewTicker(d.flushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := d.flushPendingUpdates(); err != nil { + return fmt.Errorf("pebble: flush error: %v", err) + } + case <-d.done: + if err := d.flushPendingUpdates(); err != nil { + return fmt.Errorf("pebble: final flush error: %v", err) + } + return nil + } + } +} + +// flushPendingUpdates atomically swaps the coalescing map for a fresh one, +// then applies every pending character update inside a single Batch commit. +// +// Each call to UpdateCharacter between ticks simply overwrites the map entry +// for that character ID. N calls for the same character → exactly 1 Set in +// Pebble. All of those Sets land in one batch → one WAL append regardless of +// how many characters were updated. +func (d *pebbleDB) flushPendingUpdates() error { + d.coalesceMu.Lock() + if len(d.pendingUpdates) == 0 { + d.coalesceMu.Unlock() + return nil + } + snapshot := d.pendingUpdates + d.pendingUpdates = make(map[string]pendingUpdate) + d.coalesceMu.Unlock() + + return d.exec(func(b *pebble.Batch) error { + for charIDStr, upd := range snapshot { + if err := d.applyCharacterUpdate(b, charIDStr, upd); err != nil { + return fmt.Errorf("flush update for %s: %w", charIDStr, err) + } + } + return nil + }) } \ No newline at end of file diff --git a/internal/database/pebble/pebble_test.go b/internal/database/pebble/pebble_test.go new file mode 100644 index 0000000..6251d4d --- /dev/null +++ b/internal/database/pebble/pebble_test.go @@ -0,0 +1,631 @@ +package pebble + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/msrevive/nexus2/internal/bitmask" + "github.com/msrevive/nexus2/internal/database" + //"github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestDB creates a fresh in-memory SQLite database for each test. +// Using a unique URI per test prevents cross-test contamination while +// still exercising the real schema migration and write worker. +func newTestDB(t *testing.T) *pebbleDB { + t.Helper() + db := New() + cfg := database.Config{} + cfg.Pebble.Directory = "" + require.NoError(t, db.Connect(cfg, database.Options{})) + t.Cleanup(func() { _ = db.Disconnect() }) + return db +} + +// seedUser inserts a user row directly via NewCharacter (which upserts the user) +// or via SetUserFlags after a character has been created. For tests that only +// need a user without characters we create a throwaway character then delete it. +func seedUser(t *testing.T, db *pebbleDB, steamid string) { + t.Helper() + _, err := db.NewCharacter(steamid, 0, 1, "seed") + require.NoError(t, err) +} + +// seedCharacter creates a character and returns its ID. +func seedCharacter(t *testing.T, db *pebbleDB, steamid string, slot, size int, data string) uuid.UUID { + t.Helper() + id, err := db.NewCharacter(steamid, slot, size, data) + require.NoError(t, err) + return id +} + +// flush waits long enough for the coalescing flush worker to commit any +// pending UpdateCharacter calls (default interval is 500 ms). +func flush(t *testing.T, db *pebbleDB) { + t.Helper() + require.NoError(t, db.RunGC()) // RunGC always calls flushPendingUpdates +} + +// ─── Connect / Disconnect ──────────────────────────────────────────────────── + +func TestConnect_CreatesSchema(t *testing.T) { + // If migrate fails the Connect call itself returns an error. + db := newTestDB(t) + assert.NotNil(t, db) +} + +// ─── User tests ────────────────────────────────────────────────────────────── + +func TestGetUser_Found(t *testing.T) { + db := newTestDB(t) + charID := seedCharacter(t, db, "steam1", 0, 100, "data") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, "steam1", u.ID) + assert.Equal(t, charID, u.Characters[0]) +} + +func TestGetUser_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetUser("nobody") + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUser_LoadsDeletedCharacters(t *testing.T) { + db := newTestDB(t) + charID := seedCharacter(t, db, "steam1", 0, 100, "data") + //_, errrrrrrrrr := db.GetCharacter(charID) + //require.NoError(t, errrrrrrrrr) + require.NoError(t, db.SoftDeleteCharacter(charID, 24*time.Hour)) + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, charID, u.DeletedCharacters[0]) + assert.Empty(t, u.Characters) // no longer in the active map +} + +// ─── User flag tests ───────────────────────────────────────────────────────── + +func TestSetAndGetUserFlags(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + flags := bitmask.Bitmask(0b1010) + require.NoError(t, db.SetUserFlags("steam1", flags)) + + got, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, flags, got) +} + +func TestSetUserFlags_UserNotFound(t *testing.T) { + db := newTestDB(t) + err := db.SetUserFlags("ghost", bitmask.Bitmask(1)) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUserFlags_UserNotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetUserFlags("ghost") + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUserFlags_DefaultZero(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + flags, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, bitmask.Bitmask(0), flags) +} + +func TestSetUserFlags_Overwrite(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + require.NoError(t, db.SetUserFlags("steam1", bitmask.Bitmask(0xFF))) + require.NoError(t, db.SetUserFlags("steam1", bitmask.Bitmask(0x01))) + + flags, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, bitmask.Bitmask(0x01), flags) +} + +// ─── NewCharacter ───────────────────────────────────────────────────────────── + +func TestNewCharacter_ReturnsUniqueIDs(t *testing.T) { + db := newTestDB(t) + id1 := seedCharacter(t, db, "steam1", 0, 100, "a") + id2 := seedCharacter(t, db, "steam1", 1, 100, "b") + assert.NotEqual(t, id1, id2) +} + +func TestNewCharacter_CreatesUserIfMissing(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "newuser", 0, 10, "x") + + u, err := db.GetUser("newuser") + require.NoError(t, err) + assert.Equal(t, "newuser", u.ID) +} + +func TestNewCharacter_Idempotent_UserUpsert(t *testing.T) { + db := newTestDB(t) + // Two characters for the same user should not violate a UNIQUE constraint + // on the users table. + seedCharacter(t, db, "steam1", 0, 10, "a") + seedCharacter(t, db, "steam1", 1, 20, "b") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Len(t, u.Characters, 2) +} + +// ─── GetCharacter ───────────────────────────────────────────────────────────── + +func TestGetCharacter_Found(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 42, "mydata") + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, id, c.ID) + assert.Equal(t, "steam1", c.SteamID) + assert.Equal(t, 0, c.Slot) + assert.Equal(t, 42, c.Data.Size) + assert.Equal(t, "mydata", c.Data.Data) + assert.Nil(t, c.DeletedAt) +} + +func TestGetCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetCharacter(uuid.New()) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetCharacter_HasNoVersionsInitially(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Empty(t, c.Versions) +} + +// ─── GetCharacters ──────────────────────────────────────────────────────────── + +func TestGetCharacters_ReturnsActiveOnly(t *testing.T) { + db := newTestDB(t) + id0 := seedCharacter(t, db, "steam1", 0, 10, "slot0") + id1 := seedCharacter(t, db, "steam1", 1, 20, "slot1") + + // Soft-delete slot 1 — it should NOT appear in GetCharacters. + require.NoError(t, db.SoftDeleteCharacter(id1, time.Hour)) + + chars, err := db.GetCharacters("steam1") + require.NoError(t, err) + assert.Len(t, chars, 1) + assert.Equal(t, id0, chars[0].ID) +} + +func TestGetCharacters_Empty(t *testing.T) { + db := newTestDB(t) + chars, err := db.GetCharacters("nobody") + assert.ErrorIs(t, err, database.ErrNoDocument) + assert.Empty(t, chars) +} + +func TestGetCharacters_KeyedBySlot(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "steam1", 3, 10, "three") + seedCharacter(t, db, "steam1", 7, 20, "seven") + + chars, err := db.GetCharacters("steam1") + require.NoError(t, err) + assert.Equal(t, "three", chars[3].Data.Data) + assert.Equal(t, "seven", chars[7].Data.Data) +} + +// ─── LookUpCharacterID ─────────────────────────────────────────────────────── + +func TestLookUpCharacterID_Found(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 2, 10, "data") + + got, err := db.LookUpCharacterID("steam1", 2) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestLookUpCharacterID_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.LookUpCharacterID("steam1", 99) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestLookUpCharacterID_IgnoresSoftDeleted(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + _, err := db.LookUpCharacterID("steam1", 0) + require.NoError(t, err) +} + +// ─── UpdateCharacter ───────────────────────────────────────────────────────── + +func TestUpdateCharacter_CoalescedFlush(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "original") + + // Two back-to-back updates — only the last should persist. + require.NoError(t, db.UpdateCharacter(id, 20, "second", 0, 0)) + require.NoError(t, db.UpdateCharacter(id, 30, "third", 0, 0)) + + flush(t, db) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, 30, c.Data.Size) + assert.Equal(t, "third", c.Data.Data) +} + +func TestUpdateCharacter_CreatesFirstVersion(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "v0") + + require.NoError(t, db.UpdateCharacter(id, 20, "v1", 5, 0)) + flush(t, db) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Len(t, c.Versions, 1) + assert.Equal(t, "v0", c.Versions[0].Data) +} + +func TestUpdateCharacter_RespectsBackupMax(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "init") + + // With backupMax=2 and backupTime=0 (always snapshot), after 3 updates + // there should be at most 2 versions. + for i, payload := range []string{"a", "b", "c"} { + require.NoError(t, db.UpdateCharacter(id, i+1, payload, 2, 0)) + flush(t, db) + } + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.LessOrEqual(t, len(c.Versions), 2) +} + +// ─── SoftDeleteCharacter / RestoreCharacter ─────────────────────────────────── + +func TestSoftDeleteCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.SoftDeleteCharacter(id, 24*time.Hour)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotNil(t, c.DeletedAt, "deleted_at should be set after soft delete") +} + +func TestSoftDeleteCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + err := db.SoftDeleteCharacter(uuid.New(), time.Hour) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestSoftDeleteCharacter_AppearsInDeletedCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, id, u.DeletedCharacters[0]) +} + +func TestRestoreCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + require.NoError(t, db.RestoreCharacter(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Nil(t, c.DeletedAt) + + // Should reappear in active characters. + got, err := db.LookUpCharacterID("steam1", 0) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestRestoreCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + err := db.RestoreCharacter(uuid.New()) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── DeleteCharacter ───────────────────────────────────────────────────────── + +func TestDeleteCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.DeleteCharacter(id)) + + _, err := db.GetCharacter(id) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── DeleteCharacterReference ───────────────────────────────────────────────── + +func TestDeleteCharacterReference_RemovesActiveSlot(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.DeleteCharacterReference("steam1", 0)) + + _, err := db.LookUpCharacterID("steam1", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestDeleteCharacterReference_NoopWhenMissing(t *testing.T) { + db := newTestDB(t) + // Deleting a reference that doesn't exist should not return an error. + assert.NoError(t, db.DeleteCharacterReference("nobody", 99)) +} + +// ─── MoveCharacter ──────────────────────────────────────────────────────────── + +func TestMoveCharacter(t *testing.T) { + db := newTestDB(t) + // Both users must exist; MoveCharacter checks for the target user. + id := seedCharacter(t, db, "steam1", 0, 10, "data") + seedUser(t, db, "steam2") + + require.NoError(t, db.MoveCharacter(id, "steam2", 3)) + + // Character now belongs to steam2 slot 3. + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "steam2", c.SteamID) + assert.Equal(t, 3, c.Slot) + + // Old slot on steam1 should be gone. + _, err = db.LookUpCharacterID("steam1", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) + + // New slot on steam2 should resolve. + got, err := db.LookUpCharacterID("steam2", 3) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestMoveCharacter_CharacterNotFound(t *testing.T) { + db := newTestDB(t) + err := db.MoveCharacter(uuid.New(), "steam2", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestMoveCharacter_TargetUserNotFound(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + err := db.MoveCharacter(id, "ghost", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── CopyCharacter ──────────────────────────────────────────────────────────── + +func TestCopyCharacter(t *testing.T) { + db := newTestDB(t) + origID := seedCharacter(t, db, "steam1", 0, 42, "original") + + newID, err := db.CopyCharacter(origID, "steam2", 1) + require.NoError(t, err) + assert.NotEqual(t, origID, newID) + + // Original unchanged. + orig, err := db.GetCharacter(origID) + require.NoError(t, err) + assert.Equal(t, "steam1", orig.SteamID) + + // Copy has correct owner and payload. + copy, err := db.GetCharacter(newID) + require.NoError(t, err) + assert.Equal(t, "steam2", copy.SteamID) + assert.Equal(t, 1, copy.Slot) + assert.Equal(t, "original", copy.Data.Data) + assert.Equal(t, 42, copy.Data.Size) +} + +func TestCopyCharacter_CreatesTargetUserIfMissing(t *testing.T) { + db := newTestDB(t) + origID := seedCharacter(t, db, "steam1", 0, 10, "data") + + _, err := db.CopyCharacter(origID, "brandnew", 0) + require.NoError(t, err) + + u, err := db.GetUser("brandnew") + require.NoError(t, err) + assert.Equal(t, "brandnew", u.ID) +} + +func TestCopyCharacter_OriginalNotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.CopyCharacter(uuid.New(), "steam2", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── RollbackCharacter ──────────────────────────────────────────────────────── + +func TestRollbackCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + // Create a version by updating once (backupMax>0, backupTime=0 means always snapshot). + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + + // Rollback to version index 0 (the "v0" snapshot). + require.NoError(t, db.RollbackCharacter(id, 0)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "v0", c.Data.Data) + assert.Equal(t, 1, c.Data.Size) +} + +func TestRollbackCharacter_InvalidIndex(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + err := db.RollbackCharacter(id, 99) + assert.Error(t, err) +} + +func TestRollbackCharacterToLatest(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + require.NoError(t, db.UpdateCharacter(id, 3, "v2", 5, 0)) + flush(t, db) + + // Manually clobber the current data to simulate corruption. + require.NoError(t, db.UpdateCharacter(id, 0, "corrupt", 0, 0)) + flush(t, db) + + require.NoError(t, db.RollbackCharacterToLatest(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + // Should have rolled back to the latest version (v2, since backupMax was 5). + assert.NotEqual(t, "corrupt", c.Data.Data) +} + +func TestRollbackCharacterToLatest_NoVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + err := db.RollbackCharacterToLatest(id) + assert.Error(t, err) +} + +// ─── DeleteCharacterVersions ───────────────────────────────────────────────── + +func TestDeleteCharacterVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + + require.NoError(t, db.DeleteCharacterVersions(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Empty(t, c.Versions) +} + +func TestDeleteCharacterVersions_NoVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + // Should succeed even if there are no versions to delete. + assert.NoError(t, db.DeleteCharacterVersions(id)) +} + +// ─── SyncToDisk / RunGC ────────────────────────────────────────────────────── + +func TestSyncToDisk(t *testing.T) { + db := newTestDB(t) + assert.NoError(t, db.SyncToDisk()) +} + +func TestRunGC_PurgesExpiredCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + // Use a negative duration so expires_at is already in the past. + require.NoError(t, db.SoftDeleteCharacter(id, -1*time.Second)) + + require.NoError(t, db.RunGC()) + + _, err := db.GetCharacter(id) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestRunGC_KeepsNonExpiredCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.SoftDeleteCharacter(id, 24*time.Hour)) + require.NoError(t, db.RunGC()) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotNil(t, c) +} + +func TestRunGC_FlushesBeforeGC(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "old") + + require.NoError(t, db.UpdateCharacter(id, 99, "new", 0, 0)) + // RunGC should flush the pending update before running the GC query. + require.NoError(t, db.RunGC()) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "new", c.Data.Data) +} + +// ─── schema.User integrity ─────────────────────────────────────────────────── + +func TestGetUser_CharacterMapsAreInitialized(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + // Maps must not be nil so callers can safely do map[key] lookups. + assert.NotNil(t, u.Characters) + assert.Nil(t, u.DeletedCharacters) +} + +// ─── Concurrency / coalescing sanity check ─────────────────────────────────── + +func TestUpdateCharacter_ConcurrentUpdatesCoalesce(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "init") + + const workers = 20 + done := make(chan struct{}) + for i := 0; i < workers; i++ { + i := i + go func() { + _ = db.UpdateCharacter(id, i, fmt.Sprintf("payload-%d", i), 0, 0) + done <- struct{}{} + }() + } + for i := 0; i < workers; i++ { + <-done + } + + flush(t, db) + + // We don't care which payload won — just that the DB is consistent. + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotEmpty(t, c.Data.Data) +} \ No newline at end of file diff --git a/internal/database/postgres/character.go b/internal/database/postgres/character.go new file mode 100644 index 0000000..5878818 --- /dev/null +++ b/internal/database/postgres/character.go @@ -0,0 +1,510 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/msrevive/nexus2/internal/database" + "github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// NewCharacter creates the user row (if missing) and the character row in a +// single transaction so they are always consistent. +func (d *postgresDB) NewCharacter(steamid string, slot int, size int, data string) (uuid.UUID, error) { + charID := uuid.New() + now := time.Now().UTC() + ctx := context.Background() + + err := d.execTx(ctx, func(tx pgx.Tx) error { + // Upsert the user. + _, err := tx.Exec(ctx, + `INSERT INTO users (id) VALUES ($1) ON CONFLICT(id) DO NOTHING`, + steamid, + ) + if err != nil { + return fmt.Errorf("upsert user: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO characters + (id, steam_id, slot, created_at, data_created_at, data_size, data_payload) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + charID, steamid, slot, now, now, size, data, + ) + if err != nil { + return fmt.Errorf("insert character: %w", err) + } + + return nil + }) + if err != nil { + return uuid.Nil, err + } + return charID, nil +} + +// UpdateCharacter stores the latest state in the coalescing map. The next +// flushWorker tick will commit all coalesced updates in a single transaction. +func (d *postgresDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { + d.coalesceMu.Lock() + d.pendingUpdates[id] = pendingUpdate{ + size: size, + data: data, + backupMax: backupMax, + backupTime: backupTime, + } + d.coalesceMu.Unlock() + return nil +} + +// applyCharacterUpdate is called inside the flush transaction. It performs the +// read-modify-write cycle for one character, applying version/backup logic. +func applyCharacterUpdate(ctx context.Context, tx pgx.Tx, id uuid.UUID, upd pendingUpdate) error { + var ( + dataCreatedAt time.Time + dataSize int + dataPayload string + ) + err := tx.QueryRow(ctx, ` + SELECT data_created_at, data_size, data_payload + FROM characters WHERE id = $1`, + id, + ).Scan(&dataCreatedAt, &dataSize, &dataPayload) + + if err == pgx.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // ------------------------------------------------------------------ + // Version / backup logic + // ------------------------------------------------------------------ + if upd.backupMax > 0 { + var versionCount int + if err := tx.QueryRow(ctx, + `SELECT COUNT(*) FROM character_versions WHERE character_id = $1`, + id, + ).Scan(&versionCount); err != nil { + return err + } + + // If at the cap, delete the oldest entry. + if versionCount >= upd.backupMax { + if _, err := tx.Exec(ctx, ` + DELETE FROM character_versions WHERE id = ( + SELECT id FROM character_versions + WHERE character_id = $1 ORDER BY id ASC LIMIT 1 + )`, id, + ); err != nil { + return err + } + versionCount-- + } + + if versionCount > 0 { + var newestCreatedAt time.Time + err := tx.QueryRow(ctx, ` + SELECT created_at FROM character_versions + WHERE character_id = $1 ORDER BY id DESC LIMIT 1`, + id, + ).Scan(&newestCreatedAt) + if err != nil { + return err + } + + if dataCreatedAt.After(newestCreatedAt.Add(upd.backupTime)) { + if _, err := tx.Exec(ctx, ` + INSERT INTO character_versions (character_id, created_at, size, data_payload) + VALUES ($1, $2, $3, $4)`, + id, dataCreatedAt, dataSize, dataPayload, + ); err != nil { + return err + } + } + } else { + // No versions yet — always snapshot. + if _, err := tx.Exec(ctx, ` + INSERT INTO character_versions (character_id, created_at, size, data_payload) + VALUES ($1, $2, $3, $4)`, + id, dataCreatedAt, dataSize, dataPayload, + ); err != nil { + return err + } + } + } + + // Write the new current character data. + _, err = tx.Exec(ctx, ` + UPDATE characters + SET data_created_at = $1, data_size = $2, data_payload = $3 + WHERE id = $4`, + time.Now().UTC(), upd.size, upd.data, id, + ) + return err +} + +func (d *postgresDB) GetCharacter(id uuid.UUID) (*schema.Character, error) { + ctx := context.Background() + c := &schema.Character{ID: id} + + var ( + steamID pgtype.Text + slot pgtype.Int4 + deletedAt pgtype.Timestamptz + ) + + err := d.db.QueryRow(ctx, ` + SELECT steam_id, slot, created_at, deleted_at, + data_created_at, data_size, data_payload + FROM characters WHERE id = $1`, + id, + ).Scan( + &steamID, &slot, &c.CreatedAt, &deletedAt, + &c.Data.CreatedAt, &c.Data.Size, &c.Data.Data, + ) + if err == pgx.ErrNoRows { + return nil, database.ErrNoDocument + } + if err != nil { + return nil, err + } + c.SteamID = steamID.String + c.Slot = int(slot.Int32) + if deletedAt.Valid { + c.DeletedAt = &deletedAt.Time + } + + // Load version history. + rows, err := d.db.Query(ctx, ` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = $1 ORDER BY id ASC`, + id, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var v schema.CharacterData + if err := rows.Scan(&v.CreatedAt, &v.Size, &v.Data); err != nil { + return nil, err + } + c.Versions = append(c.Versions, v) + } + return c, rows.Err() +} + +func (d *postgresDB) GetCharacters(steamid string) (map[int]schema.Character, error) { + ctx := context.Background() + rows, err := d.db.Query(ctx, ` + SELECT id, slot, created_at, deleted_at, data_created_at, data_size, data_payload + FROM characters + WHERE steam_id = $1 AND deleted_at IS NULL`, + steamid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + chars := make(map[int]schema.Character) + for rows.Next() { + var ( + c schema.Character + deletedAt *time.Time + ) + err := rows.Scan( + &c.ID, &c.Slot, &c.CreatedAt, &deletedAt, + &c.Data.CreatedAt, &c.Data.Size, &c.Data.Data, + ) + if err != nil { + return nil, err + } + c.SteamID = steamid + c.DeletedAt = deletedAt + chars[c.Slot] = c + } + return chars, rows.Err() +} + +func (d *postgresDB) LookUpCharacterID(steamid string, slot int) (uuid.UUID, error) { + ctx := context.Background() + var id uuid.UUID + err := d.db.QueryRow(ctx, ` + SELECT id FROM characters + WHERE steam_id = $1 AND slot = $2 AND deleted_at IS NULL`, + steamid, slot, + ).Scan(&id) + if err == pgx.ErrNoRows { + return uuid.Nil, database.ErrNoDocument + } + if err != nil { + return uuid.Nil, err + } + return id, nil +} + +// SoftDeleteCharacter sets deleted_at + expires_at on the character and records +// the slot in deleted_characters so it can be restored or GC'd later. +func (d *postgresDB) SoftDeleteCharacter(id uuid.UUID, expiration time.Duration) error { + now := time.Now().UTC() + expiresAt := now.Add(expiration) + ctx := context.Background() + + return d.execTx(ctx, func(tx pgx.Tx) error { + var steamID string + var slot int + err := tx.QueryRow(ctx, + `SELECT steam_id, slot FROM characters WHERE id = $1`, id, + ).Scan(&steamID, &slot) + if err == pgx.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // we orphan the character data here. + if _, err := tx.Exec(ctx, ` + UPDATE characters SET deleted_at = $1, expires_at = $2, steam_id = NULL, slot = NULL WHERE id = $3`, + now, expiresAt, id, + ); err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + INSERT INTO deleted_characters (steam_id, slot, character_id, deleted_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (steam_id, slot) DO UPDATE + SET character_id = EXCLUDED.character_id, + deleted_at = EXCLUDED.deleted_at`, + steamID, slot, id, now, + ) + return err + }) +} + +// DeleteCharacter permanently removes the character and all associated data. +func (d *postgresDB) DeleteCharacter(id uuid.UUID) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM characters WHERE id = $1`, id) + return err + }) +} + +// DeleteCharacterReference removes the active slot→character mapping for a user. +func (d *postgresDB) DeleteCharacterReference(steamid string, slot int) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE characters SET steam_id = NULL, slot = NULL + WHERE steam_id = $1 AND slot = $2 AND deleted_at IS NULL`, + steamid, slot, + ) + return err + }) +} + +// MoveCharacter transfers a character to a different user/slot atomically. +func (d *postgresDB) MoveCharacter(id uuid.UUID, steamid string, slot int) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + var oldSteamID string + var oldSlot int + err := tx.QueryRow(ctx, + `SELECT steam_id, slot FROM characters WHERE id = $1`, id, + ).Scan(&oldSteamID, &oldSlot) + if err == pgx.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // Ensure target user exists. + var exists int + if err := tx.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE id = $1`, steamid, + ).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return database.ErrNoDocument + } + + // Clear old slot. + if _, err := tx.Exec(ctx, ` + UPDATE characters SET steam_id = NULL, slot = NULL + WHERE steam_id = $1 AND slot = $2 AND deleted_at IS NULL`, + oldSteamID, oldSlot, + ); err != nil { + return err + } + + // Assign to new owner. + _, err = tx.Exec(ctx, ` + UPDATE characters SET steam_id = $1, slot = $2, deleted_at = NULL + WHERE id = $3`, + steamid, slot, id, + ) + return err + }) +} + +// CopyCharacter duplicates a character's current data under a new UUID. +func (d *postgresDB) CopyCharacter(id uuid.UUID, steamid string, slot int) (uuid.UUID, error) { + newID := uuid.New() + now := time.Now().UTC() + ctx := context.Background() + + err := d.execTx(ctx, func(tx pgx.Tx) error { + var dataCreatedAt time.Time + var dataSize int + var dataPayload string + err := tx.QueryRow(ctx, ` + SELECT data_created_at, data_size, data_payload + FROM characters WHERE id = $1`, + id, + ).Scan(&dataCreatedAt, &dataSize, &dataPayload) + if err == pgx.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + if _, err := tx.Exec(ctx, + `INSERT INTO users (id) VALUES ($1) ON CONFLICT(id) DO NOTHING`, steamid, + ); err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + INSERT INTO characters + (id, steam_id, slot, created_at, data_created_at, data_size, data_payload) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + newID, steamid, slot, now, dataCreatedAt, dataSize, dataPayload, + ) + return err + }) + if err != nil { + return uuid.Nil, err + } + return newID, nil +} + +// RestoreCharacter clears the soft-delete markers and makes the character active again. +func (d *postgresDB) RestoreCharacter(id uuid.UUID) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + var steamID string + var slot int + err := tx.QueryRow(ctx, + `SELECT steam_id, slot FROM deleted_characters WHERE character_id = $1`, id, + ).Scan(&steamID, &slot) + if err == pgx.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + if _, err := tx.Exec(ctx, ` + UPDATE characters SET deleted_at = NULL, expires_at = NULL, steam_id = $1, slot = $2 WHERE id = $3`, + steamID, slot, id, + ); err != nil { + return err + } + + _, err = tx.Exec(ctx, + `DELETE FROM deleted_characters WHERE character_id = $1`, + id, + ) + return err + }) +} + +// RollbackCharacter replaces the current character data with the version at +// index ver (0-based, ordered oldest → newest). +func (d *postgresDB) RollbackCharacter(id uuid.UUID, ver int) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + var createdAt time.Time + var size int + var payload string + err := tx.QueryRow(ctx, ` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = $1 + ORDER BY id ASC + LIMIT 1 OFFSET $2`, + id, ver, + ).Scan(&createdAt, &size, &payload) + if err == pgx.ErrNoRows { + return fmt.Errorf("no character version at index %d", ver) + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + UPDATE characters + SET data_created_at = $1, data_size = $2, data_payload = $3 + WHERE id = $4`, + createdAt, size, payload, id, + ) + return err + }) +} + +// RollbackCharacterToLatest replaces the current data with the most recent version. +func (d *postgresDB) RollbackCharacterToLatest(id uuid.UUID) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + var createdAt time.Time + var size int + var payload string + err := tx.QueryRow(ctx, ` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = $1 + ORDER BY id DESC LIMIT 1`, + id, + ).Scan(&createdAt, &size, &payload) + if err == pgx.ErrNoRows { + return fmt.Errorf("no character backups exist") + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + UPDATE characters + SET data_created_at = $1, data_size = $2, data_payload = $3 + WHERE id = $4`, + createdAt, size, payload, id, + ) + return err + }) +} + +// DeleteCharacterVersions wipes all version history for a character. +func (d *postgresDB) DeleteCharacterVersions(id uuid.UUID) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, + `DELETE FROM character_versions WHERE character_id = $1`, id, + ) + return err + }) +} diff --git a/internal/database/postgres/postgres.go b/internal/database/postgres/postgres.go new file mode 100644 index 0000000..f963008 --- /dev/null +++ b/internal/database/postgres/postgres.go @@ -0,0 +1,244 @@ +package postgres + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/msrevive/nexus2/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// pendingUpdate holds the latest state for a character that has been +// updated but not yet flushed to the database. +type pendingUpdate struct { + size int + data string + backupMax int + backupTime time.Duration +} + +type postgresDB struct { + db *pgxpool.Pool + + // flushInterval controls how often the coalescing buffer is drained. + flushInterval time.Duration + + // pendingUpdates is the coalescing map. When UpdateCharacter is called, + // we just overwrite the entry for that character ID. On each flush tick, + // all pending entries are committed in a single transaction. + coalesceMu sync.Mutex + pendingUpdates map[uuid.UUID]pendingUpdate + + done chan struct{} + wg sync.WaitGroup + + database.Options +} + +func New() *postgresDB { + return &postgresDB{ + flushInterval: 500 * time.Millisecond, + pendingUpdates: make(map[uuid.UUID]pendingUpdate), + done: make(chan struct{}), + } +} + +func (d *postgresDB) Connect(cfg database.Config, opts database.Options) error { + ctx := context.Background() + + poolCfg, err := pgxpool.ParseConfig(cfg.Postgres.Conn) + if err != nil { + return fmt.Errorf("postgres: parse dsn: %w", err) + } + + poolCfg.MinConns = cfg.Postgres.MinConns + poolCfg.MaxConns = cfg.Postgres.MaxConns + + // Health-check idle connections periodically so stale connections to a + // remote instance (which may be behind a load-balancer or firewall with + // idle timeouts) are replaced before they cause query failures. + poolCfg.HealthCheckPeriod = 30 * time.Second + + // Keep idle connections alive for a reasonable window. Managed instances + // (e.g. RDS, Cloud SQL) often terminate connections idle > 10 min. + poolCfg.MaxConnIdleTime = 5 * time.Minute + poolCfg.MaxConnLifetime = 30 * time.Minute + + // Per-connection timeouts protect against network partitions to the + // remote host. + poolCfg.ConnConfig.ConnectTimeout = 10 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return fmt.Errorf("postgres: create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return fmt.Errorf("postgres: ping: %w", err) + } + + d.db = pool + d.Logger = opts.Logger + + if err := migrate(ctx, pool); err != nil { + pool.Close() + return fmt.Errorf("postgres: migrate: %w", err) + } + + d.wg.Add(1) + go d.flushWorker() + + return nil +} + +func (d *postgresDB) Disconnect() error { + close(d.done) + d.wg.Wait() + d.db.Close() + return nil +} + +// SyncToDisk is a no-op for Postgres — data is durable after COMMIT. +func (d *postgresDB) SyncToDisk() error { + return nil +} + +// RunGC flushes pending updates and then purges any soft-deleted characters +// whose expiration timestamp has passed. +func (d *postgresDB) RunGC() error { + if err := d.flushPendingUpdates(); err != nil { + return err + } + + ctx := context.Background() + _, err := d.db.Exec(ctx, + `DELETE FROM characters WHERE expires_at IS NOT NULL AND expires_at <= NOW()`, + ) + return err +} + +// execTx runs fn inside a transaction. Postgres supports multiple concurrent +// writers, so there is no need for a serialized write channel. +func (d *postgresDB) execTx(ctx context.Context, fn func(tx pgx.Tx) error) error { + tx, err := d.db.Begin(ctx) + if err != nil { + return err + } + if err := fn(tx); err != nil { + _ = tx.Rollback(ctx) + return err + } + return tx.Commit(ctx) +} + +// flushWorker ticks on flushInterval and drains the coalescing buffer. +// On shutdown it performs one final flush so no updates are lost. +func (d *postgresDB) flushWorker() { + defer d.wg.Done() + ticker := time.NewTicker(d.flushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := d.flushPendingUpdates(); err != nil && d.Logger != nil { + d.Logger.Fatalln("postgres: flush error", "error", err) + } + + case <-d.done: + _ = d.flushPendingUpdates() + return + } + } +} + +// flushPendingUpdates atomically swaps the coalescing map for a fresh one, +// then commits all coalesced updates in a single transaction. +func (d *postgresDB) flushPendingUpdates() error { + d.coalesceMu.Lock() + if len(d.pendingUpdates) == 0 { + d.coalesceMu.Unlock() + return nil + } + snapshot := d.pendingUpdates + d.pendingUpdates = make(map[uuid.UUID]pendingUpdate) + d.coalesceMu.Unlock() + + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + for id, upd := range snapshot { + if err := applyCharacterUpdate(ctx, tx, id, upd); err != nil { + return fmt.Errorf("flush update for %s: %w", id, err) + } + } + return nil + }) +} + +// migrate creates the schema on first run. Uses Postgres-native types. +func migrate(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS characters ( + id UUID PRIMARY KEY, + steam_id TEXT REFERENCES users(id), + slot INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + data_created_at TIMESTAMPTZ, + data_size INTEGER NOT NULL DEFAULT 0, + data_payload TEXT NOT NULL DEFAULT '', + UNIQUE (steam_id, slot) + ); + + CREATE TABLE IF NOT EXISTS deleted_characters ( + steam_id TEXT NOT NULL REFERENCES users(id), + slot INTEGER NOT NULL, + character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (steam_id, slot), + UNIQUE (character_id) + ); + + CREATE TABLE IF NOT EXISTS character_versions ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + size INTEGER NOT NULL, + data_payload TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_chars_steam_id ON characters(steam_id); + CREATE INDEX IF NOT EXISTS idx_charver_char_id ON character_versions(character_id); + `) + return err +} + +// pgErr is a helper to check for specific Postgres error codes if needed. +func pgErr(err error) *pgconn.PgError { + var pgError *pgconn.PgError + if err != nil { + if ok := pgx.ErrNoRows; err == ok { + return nil + } + if e, ok := err.(*pgconn.PgError); ok { + return e + } + } + _ = pgError + return nil +} diff --git a/internal/database/postgres/users.go b/internal/database/postgres/users.go new file mode 100644 index 0000000..4f94dfa --- /dev/null +++ b/internal/database/postgres/users.go @@ -0,0 +1,178 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/msrevive/nexus2/internal/bitmask" + "github.com/msrevive/nexus2/internal/database" + "github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (d *postgresDB) GetAllUsers() ([]*schema.User, error) { + ctx := context.Background() + + rows, err := d.db.Query(ctx, ` + SELECT + u.id, u.revision, u.flags, + c.slot AS char_slot, + c.id AS char_id, + dc.slot AS del_slot, + dc.character_id AS del_char_id + FROM users u + LEFT JOIN characters c + ON c.steam_id = u.id AND c.deleted_at IS NULL + LEFT JOIN deleted_characters dc + ON dc.steam_id = u.id + ORDER BY u.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + userMap := make(map[string]*schema.User) + var order []string + + for rows.Next() { + var ( + id string + revision int + flags uint32 + charSlot *int + charID *uuid.UUID + delSlot *int + delID *uuid.UUID + ) + if err := rows.Scan(&id, &revision, &flags, &charSlot, &charID, &delSlot, &delID); err != nil { + return nil, err + } + + u, exists := userMap[id] + if !exists { + u = &schema.User{ + ID: id, + Revision: revision, + Flags: flags, + Characters: make(map[int]uuid.UUID), + DeletedCharacters: make(map[int]uuid.UUID), + } + userMap[id] = u + order = append(order, id) + } + + if charSlot != nil && charID != nil { + u.Characters[*charSlot] = *charID + } + if delSlot != nil && delID != nil { + u.DeletedCharacters[*delSlot] = *delID + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + users := make([]*schema.User, 0, len(order)) + for _, id := range order { + users = append(users, userMap[id]) + } + return users, nil +} + +func (d *postgresDB) GetUser(steamid string) (*schema.User, error) { + ctx := context.Background() + + rows, err := d.db.Query(ctx, ` + SELECT + u.id, u.revision, u.flags, + c.slot AS char_slot, + c.id AS char_id, + dc.slot AS del_slot, + dc.character_id AS del_char_id + FROM users u + LEFT JOIN characters c + ON c.steam_id = u.id AND c.deleted_at IS NULL + LEFT JOIN deleted_characters dc + ON dc.steam_id = u.id + WHERE u.id = $1`, + steamid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var u *schema.User + for rows.Next() { + var ( + id string + revision int + flags uint32 + charSlot *int + charID *uuid.UUID + delSlot *int + delID *uuid.UUID + ) + if err := rows.Scan(&id, &revision, &flags, &charSlot, &charID, &delSlot, &delID); err != nil { + return nil, err + } + + if u == nil { + u = &schema.User{ + ID: id, + Revision: revision, + Flags: flags, + Characters: make(map[int]uuid.UUID), + DeletedCharacters: make(map[int]uuid.UUID), + } + } + + if charSlot != nil && charID != nil { + u.Characters[*charSlot] = *charID + } + if delSlot != nil && delID != nil { + u.DeletedCharacters[*delSlot] = *delID + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + if u == nil { + return nil, database.ErrNoDocument + } + return u, nil +} + +func (d *postgresDB) SetUserFlags(steamid string, flags bitmask.Bitmask) error { + ctx := context.Background() + return d.execTx(ctx, func(tx pgx.Tx) error { + ct, err := tx.Exec(ctx, + `UPDATE users SET flags = $1 WHERE id = $2`, uint32(flags), steamid, + ) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return database.ErrNoDocument + } + return nil + }) +} + +func (d *postgresDB) GetUserFlags(steamid string) (bitmask.Bitmask, error) { + ctx := context.Background() + var flags uint32 + err := d.db.QueryRow(ctx, + `SELECT flags FROM users WHERE id = $1`, steamid, + ).Scan(&flags) + if err == pgx.ErrNoRows { + return 0, database.ErrNoDocument + } + if err != nil { + return 0, fmt.Errorf("get user flags: %w", err) + } + return bitmask.Bitmask(flags), nil +} diff --git a/internal/database/sqlite/character.go b/internal/database/sqlite/character.go new file mode 100644 index 0000000..ecb9796 --- /dev/null +++ b/internal/database/sqlite/character.go @@ -0,0 +1,525 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "time" + + "github.com/msrevive/nexus2/internal/database" + "github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/google/uuid" +) + +// NewCharacter creates the user row (if missing) and the character row in a +// single transaction so they are always consistent. +func (d *sqliteDB) NewCharacter(steamid string, slot int, size int, data string) (uuid.UUID, error) { + charID := uuid.New() + now := time.Now().UTC() + + err := d.exec(func(tx *sql.Tx) error { + // Upsert the user — mirrors the pebble logic that creates a new user + // document when one doesn't exist yet. + _, err := tx.Exec( + `INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`, + steamid, + ) + if err != nil { + return fmt.Errorf("upsert user: %w", err) + } + + _, err = tx.Exec(` + INSERT INTO characters + (id, steam_id, slot, created_at, data_created_at, data_size, data_payload) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + charID.String(), steamid, slot, now, now, size, data, + ) + if err != nil { + return fmt.Errorf("insert character: %w", err) + } + + return nil + }) + if err != nil { + return uuid.Nil, err + } + return charID, nil +} + +// UpdateCharacter does NOT touch the database immediately. It stores the latest +// state for this character ID in the coalescing map and returns. The next +// flushWorker tick will commit all coalesced updates in a single transaction. +// +// This means 100 calls to UpdateCharacter for the same character within the +// flush window result in exactly 1 database write — the one with the final state. +func (d *sqliteDB) UpdateCharacter(id uuid.UUID, size int, data string, backupMax int, backupTime time.Duration) error { + d.coalesceMu.Lock() + d.pendingUpdates[id] = pendingUpdate{ + size: size, + data: data, + backupMax: backupMax, + backupTime: backupTime, + } + d.coalesceMu.Unlock() + return nil +} + +// applyCharacterUpdate is called inside the flush transaction. It performs the +// read-modify-write cycle for one character, applying the version/backup logic +// that mirrors the pebble implementation exactly. +// +// Called only from within a transaction on the write goroutine. +func applyCharacterUpdate(tx *sql.Tx, id uuid.UUID, upd pendingUpdate) error { + // Read the current character data so we can snapshot it as a version. + var ( + dataCreatedAt time.Time + dataSize int + dataPayload string + ) + err := tx.QueryRow(` + SELECT data_created_at, data_size, data_payload + FROM characters WHERE id = ?`, + id.String(), + ).Scan(&dataCreatedAt, &dataSize, &dataPayload) + + if err == sql.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // ------------------------------------------------------------------ + // Version / backup logic — mirrors the pebble UpdateCharacter exactly. + // ------------------------------------------------------------------ + if upd.backupMax > 0 { + var versionCount int + if err := tx.QueryRow( + `SELECT COUNT(*) FROM character_versions WHERE character_id = ?`, + id.String(), + ).Scan(&versionCount); err != nil { + return err + } + + // If we are at the cap, delete the oldest entry (lowest autoincrement id). + if versionCount >= upd.backupMax { + if _, err := tx.Exec(` + DELETE FROM character_versions WHERE id = ( + SELECT id FROM character_versions + WHERE character_id = ? ORDER BY id ASC LIMIT 1 + )`, id.String(), + ); err != nil { + return err + } + versionCount-- + } + + if versionCount > 0 { + // Only snapshot the current data if enough time has passed since + // the newest existing backup. This prevents rapid-fire updates from + // flooding the version table. + var newestCreatedAt time.Time + err := tx.QueryRow(` + SELECT created_at FROM character_versions + WHERE character_id = ? ORDER BY id DESC LIMIT 1`, + id.String(), + ).Scan(&newestCreatedAt) + if err != nil { + return err + } + + if dataCreatedAt.After(newestCreatedAt.Add(upd.backupTime)) { + if _, err := tx.Exec(` + INSERT INTO character_versions (character_id, created_at, size, data_payload) + VALUES (?, ?, ?, ?)`, + id.String(), dataCreatedAt, dataSize, dataPayload, + ); err != nil { + return err + } + } + } else { + // No versions yet — always snapshot the current data on the first update. + if _, err := tx.Exec(` + INSERT INTO character_versions (character_id, created_at, size, data_payload) + VALUES (?, ?, ?, ?)`, + id.String(), dataCreatedAt, dataSize, dataPayload, + ); err != nil { + return err + } + } + } + + // Write the new current character data. + _, err = tx.Exec(` + UPDATE characters + SET data_created_at = ?, data_size = ?, data_payload = ? + WHERE id = ?`, + time.Now().UTC(), upd.size, upd.data, id.String(), + ) + return err +} + +func (d *sqliteDB) GetCharacter(id uuid.UUID) (*schema.Character, error) { + c := &schema.Character{ID: id} + + var ( + steamID sql.NullString + slot sql.NullInt32 + deletedAt sql.NullTime + ) + + err := d.db.QueryRow(` + SELECT steam_id, slot, created_at, deleted_at, + data_created_at, data_size, data_payload + FROM characters WHERE id = ?`, + id.String(), + ).Scan( + &steamID, &slot, &c.CreatedAt, &deletedAt, + &c.Data.CreatedAt, &c.Data.Size, &c.Data.Data, + ) + if err == sql.ErrNoRows { + return nil, database.ErrNoDocument + } + if err != nil { + return nil, err + } + c.SteamID = steamID.String + c.Slot = int(slot.Int32) + if deletedAt.Valid { + c.DeletedAt = &deletedAt.Time + } + + // Load the versions slice (Versions []CharacterData). + rows, err := d.db.Query(` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = ? ORDER BY id ASC`, + id.String(), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var v schema.CharacterData + if err := rows.Scan(&v.CreatedAt, &v.Size, &v.Data); err != nil { + return nil, err + } + c.Versions = append(c.Versions, v) + } + return c, rows.Err() +} + +func (d *sqliteDB) GetCharacters(steamid string) (map[int]schema.Character, error) { + rows, err := d.db.Query(` + SELECT id, slot, created_at, deleted_at, data_created_at, data_size, data_payload + FROM characters + WHERE steam_id = ? AND deleted_at IS NULL`, + steamid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + chars := make(map[int]schema.Character) + for rows.Next() { + var ( + c schema.Character + idStr string + deletedAt sql.NullTime + ) + err := rows.Scan( + &idStr, &c.Slot, &c.CreatedAt, &deletedAt, + &c.Data.CreatedAt, &c.Data.Size, &c.Data.Data, + ) + if err != nil { + return nil, err + } + c.ID, _ = uuid.Parse(idStr) + c.SteamID = steamid + if deletedAt.Valid { + c.DeletedAt = &deletedAt.Time + } + chars[c.Slot] = c + } + return chars, rows.Err() +} + +func (d *sqliteDB) LookUpCharacterID(steamid string, slot int) (uuid.UUID, error) { + var idStr string + err := d.db.QueryRow(` + SELECT id FROM characters + WHERE steam_id = ? AND slot = ? AND deleted_at IS NULL`, + steamid, slot, + ).Scan(&idStr) + if err == sql.ErrNoRows { + return uuid.Nil, database.ErrNoDocument + } + if err != nil { + return uuid.Nil, err + } + return uuid.Parse(idStr) +} + +// SoftDeleteCharacter sets deleted_at + expires_at on the character and records +// the slot in deleted_characters so it can be restored or GC'd later. +func (d *sqliteDB) SoftDeleteCharacter(id uuid.UUID, expiration time.Duration) error { + now := time.Now().UTC() + expiresAt := now.Add(expiration) + + return d.exec(func(tx *sql.Tx) error { + var steamID string + var slot int + err := tx.QueryRow( + `SELECT steam_id, slot FROM characters WHERE id = ?`, id.String(), + ).Scan(&steamID, &slot) + if err == sql.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + if _, err := tx.Exec(` + UPDATE characters SET deleted_at = ?, expires_at = ?, steam_id = NULL, slot = NULL WHERE id = ?`, + now, expiresAt, id.String(), + ); err != nil { + return err + } + + // Upsert into deleted_characters to preserve the slot → id mapping + // (mirrors user.DeletedCharacters in the pebble implementation). + _, err = tx.Exec(` + INSERT INTO deleted_characters (steam_id, slot, character_id, deleted_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (steam_id, slot) DO UPDATE + SET character_id = excluded.character_id, + deleted_at = excluded.deleted_at`, + steamID, slot, id.String(), now, + ) + return err + }) +} + +// DeleteCharacter permanently removes the character and all associated data. +// cascade on character_versions handles version cleanup automatically. +func (d *sqliteDB) DeleteCharacter(id uuid.UUID) error { + return d.exec(func(tx *sql.Tx) error { + // character_versions are deleted by ON DELETE CASCADE. + _, err := tx.Exec(`DELETE FROM characters WHERE id = ?`, id.String()) + return err + }) +} + +// DeleteCharacterReference removes the active slot→character mapping for a user, +// leaving the character row intact but unowned (steam_id = NULL). +// This is called by MoveCharacter to clear the character's old slot before +// reassigning it, mirroring delete(user.Characters, slot) in the pebble version. +func (d *sqliteDB) DeleteCharacterReference(steamid string, slot int) error { + return d.exec(func(tx *sql.Tx) error { + // Nullify steam_id/slot so the character no longer occupies the slot + // on the old owner. The UNIQUE(steam_id, slot) constraint allows NULLs + // on both columns, so this is safe. + _, err := tx.Exec(` + UPDATE characters SET steam_id = NULL, slot = NULL + WHERE steam_id = ? AND slot = ? AND deleted_at IS NULL`, + steamid, slot, + ) + return err + }) +} + +// MoveCharacter transfers a character to a different user/slot atomically. +func (d *sqliteDB) MoveCharacter(id uuid.UUID, steamid string, slot int) error { + return d.exec(func(tx *sql.Tx) error { + // Fetch the character's current owner so we can clear that slot. + var oldSteamID string + var oldSlot int + err := tx.QueryRow( + `SELECT steam_id, slot FROM characters WHERE id = ?`, id.String(), + ).Scan(&oldSteamID, &oldSlot) + if err == sql.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // Ensure the target user exists. + var exists int + if err := tx.QueryRow( + `SELECT COUNT(*) FROM users WHERE id = ?`, steamid, + ).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return database.ErrNoDocument + } + + // Clear the old owner's reference by nullifying steam_id/slot so the + // UNIQUE constraint on (steam_id, slot) doesn't block the reassignment. + if _, err := tx.Exec(` + UPDATE characters SET steam_id = NULL, slot = NULL + WHERE steam_id = ? AND slot = ? AND deleted_at IS NULL`, + oldSteamID, oldSlot, + ); err != nil { + return err + } + + // Now assign the character to the new owner. + _, err = tx.Exec(` + UPDATE characters SET steam_id = ?, slot = ?, deleted_at = NULL + WHERE id = ?`, + steamid, slot, id.String(), + ) + return err + }) +} + +// CopyCharacter duplicates a character's current data under a new UUID +// assigned to the target user/slot. +func (d *sqliteDB) CopyCharacter(id uuid.UUID, steamid string, slot int) (uuid.UUID, error) { + newID := uuid.New() + now := time.Now().UTC() + + err := d.exec(func(tx *sql.Tx) error { + var dataCreatedAt time.Time + var dataSize int + var dataPayload string + err := tx.QueryRow(` + SELECT data_created_at, data_size, data_payload + FROM characters WHERE id = ?`, + id.String(), + ).Scan(&dataCreatedAt, &dataSize, &dataPayload) + if err == sql.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + // Ensure the target user exists. + if _, err := tx.Exec( + `INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`, steamid, + ); err != nil { + return err + } + + _, err = tx.Exec(` + INSERT INTO characters + (id, steam_id, slot, created_at, data_created_at, data_size, data_payload) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + newID.String(), steamid, slot, now, dataCreatedAt, dataSize, dataPayload, + ) + return err + }) + if err != nil { + return uuid.Nil, err + } + return newID, nil +} + +// RestoreCharacter clears the soft-delete markers and removes the entry from +// deleted_characters, making the character active again. +func (d *sqliteDB) RestoreCharacter(id uuid.UUID) error { + return d.exec(func(tx *sql.Tx) error { + var steamID string + var slot int + err := tx.QueryRow( + `SELECT steam_id, slot FROM deleted_characters WHERE character_id = ?`, id.String(), + ).Scan(&steamID, &slot) + if err == sql.ErrNoRows { + return database.ErrNoDocument + } + if err != nil { + return err + } + + if _, err := tx.Exec(` + UPDATE characters SET deleted_at = NULL, expires_at = NULL, steam_id = ?, slot = ? WHERE id = ?`, + steamID, slot, id.String(), + ); err != nil { + return err + } + + _, err = tx.Exec( + `DELETE FROM deleted_characters WHERE character_id = ?`, + id, + ) + return err + }) +} + +// RollbackCharacter replaces the current character data with the version at +// index ver (0-based, ordered oldest → newest). Mirrors the pebble implementation. +func (d *sqliteDB) RollbackCharacter(id uuid.UUID, ver int) error { + return d.exec(func(tx *sql.Tx) error { + var createdAt time.Time + var size int + var payload string + err := tx.QueryRow(` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = ? + ORDER BY id ASC + LIMIT 1 OFFSET ?`, + id.String(), ver, + ).Scan(&createdAt, &size, &payload) + if err == sql.ErrNoRows { + return fmt.Errorf("no character version at index %d", ver) + } + if err != nil { + return err + } + + _, err = tx.Exec(` + UPDATE characters + SET data_created_at = ?, data_size = ?, data_payload = ? + WHERE id = ?`, + createdAt, size, payload, id.String(), + ) + return err + }) +} + +// RollbackCharacterToLatest replaces the current data with the most recent version. +func (d *sqliteDB) RollbackCharacterToLatest(id uuid.UUID) error { + return d.exec(func(tx *sql.Tx) error { + var createdAt time.Time + var size int + var payload string + err := tx.QueryRow(` + SELECT created_at, size, data_payload + FROM character_versions + WHERE character_id = ? + ORDER BY id DESC LIMIT 1`, + id.String(), + ).Scan(&createdAt, &size, &payload) + if err == sql.ErrNoRows { + return fmt.Errorf("no character backups exist") + } + if err != nil { + return err + } + + _, err = tx.Exec(` + UPDATE characters + SET data_created_at = ?, data_size = ?, data_payload = ? + WHERE id = ?`, + createdAt, size, payload, id.String(), + ) + return err + }) +} + +// DeleteCharacterVersions wipes all version history for a character. +func (d *sqliteDB) DeleteCharacterVersions(id uuid.UUID) error { + return d.exec(func(tx *sql.Tx) error { + _, err := tx.Exec( + `DELETE FROM character_versions WHERE character_id = ?`, id.String(), + ) + return err + }) +} diff --git a/internal/database/sqlite/sqlite.go b/internal/database/sqlite/sqlite.go new file mode 100644 index 0000000..904369d --- /dev/null +++ b/internal/database/sqlite/sqlite.go @@ -0,0 +1,271 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "sync" + "time" + "path/filepath" + "os" + + "github.com/msrevive/nexus2/internal/database" + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +// writeOp is a unit of work sent through the serialized write channel. +// Every mutating DB call goes through here so SQLite's single-writer +// constraint is respected without any external locking. +type writeOp struct { + fn func(tx *sql.Tx) error + resp chan error +} + +// pendingUpdate holds the latest state for a character that has been +// updated but not yet flushed to the database. +type pendingUpdate struct { + size int + data string + backupMax int + backupTime time.Duration +} + +type sqliteDB struct { + db *sql.DB + + // writeCh is the single-writer channel. Only one goroutine reads from it, + // so all DB writes are naturally serialized — no locking needed for writes. + writeCh chan writeOp + + // flushInterval controls how often the coalescing buffer is drained. + flushInterval time.Duration + + // pendingUpdates is the coalescing map. When UpdateCharacter is called, + // we just overwrite the entry for that character ID. On each flush tick, + // all pending entries are committed in a single transaction. + coalesceMu sync.Mutex + pendingUpdates map[uuid.UUID]pendingUpdate + + done chan struct{} + wg sync.WaitGroup + + database.Options +} + +func New() *sqliteDB { + return &sqliteDB{ + writeCh: make(chan writeOp, 512), + flushInterval: 500 * time.Millisecond, + pendingUpdates: make(map[uuid.UUID]pendingUpdate), + done: make(chan struct{}), + } +} + +func (d *sqliteDB) Connect(cfg database.Config, opts database.Options) error { + // Ensure all parent directories exist before opening the SQLite file. + if dir := filepath.Dir(cfg.SQLite.Path); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("sqlite mkdir: %w", err) + } + } + + dsn := fmt.Sprintf("%s?_journal=WAL&_synchronous=NORMAL&_busy_timeout=5000", cfg.SQLite.Path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return err + } + + // Crucial: limit to a single open connection so SQLite's file-level + // write lock is never contended from within our own process. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + if err := db.Ping(); err != nil { + return fmt.Errorf("sqlite ping: %w", err) + } + + if err := migrate(db); err != nil { + return fmt.Errorf("sqlite migrate: %w", err) + } + + d.db = db + d.Logger = opts.Logger + + d.wg.Add(2) + go d.writeWorker() + go d.flushWorker() + + return nil +} + +func (d *sqliteDB) Disconnect() error { + close(d.done) + d.wg.Wait() + return d.db.Close() +} + +// SyncToDisk issues a passive WAL checkpoint so data in the WAL file +// is folded back into the main database file. +func (d *sqliteDB) SyncToDisk() error { + _, err := d.db.Exec("PRAGMA wal_checkpoint(PASSIVE)") + return err +} + +// RunGC flushes pending updates and then purges any soft-deleted characters +// whose expiration timestamp has passed. +func (d *sqliteDB) RunGC() error { + if err := d.flushPendingUpdates(); err != nil { + return err + } + + return d.exec(func(tx *sql.Tx) error { + _, err := tx.Exec( + `DELETE FROM characters WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`, + ) + return err + }) +} + +// exec is the public helper for ad-hoc write operations. It packages the +// function into a writeOp, ships it to the single writer goroutine, and +// blocks until the result comes back. +func (d *sqliteDB) exec(fn func(tx *sql.Tx) error) error { + resp := make(chan error, 1) + d.writeCh <- writeOp{fn: fn, resp: resp} + return <-resp +} + +// writeWorker is the ONLY goroutine that opens transactions and writes to +// the database. This gives SQLite a single writer at all times. +func (d *sqliteDB) writeWorker() { + defer d.wg.Done() + + runOp := func(op writeOp) { + tx, err := d.db.Begin() + if err != nil { + op.resp <- err + return + } + if err := op.fn(tx); err != nil { + _ = tx.Rollback() + op.resp <- err + return + } + op.resp <- tx.Commit() + } + + for { + select { + case op := <-d.writeCh: + runOp(op) + + case <-d.done: + // Drain any remaining ops that arrived before shutdown. + for { + select { + case op := <-d.writeCh: + runOp(op) + default: + return + } + } + } + } +} + +// flushWorker ticks on flushInterval and drains the coalescing buffer. +// On shutdown it performs one final flush so no updates are lost. +func (d *sqliteDB) flushWorker() error { + defer d.wg.Done() + ticker := time.NewTicker(d.flushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := d.flushPendingUpdates(); err != nil { + return fmt.Errorf("sqlite: flush error: %v", err) + } + + case <-d.done: + if err := d.flushPendingUpdates(); err != nil { + return fmt.Errorf("sqlite: final flush error: %v", err) + } + return nil + } + } +} + +// flushPendingUpdates atomically swaps the coalescing map for a fresh one, +// then commits all coalesced updates in a single transaction. N calls to +// UpdateCharacter for the same character between ticks become exactly 1 +// database write. +func (d *sqliteDB) flushPendingUpdates() error { + d.coalesceMu.Lock() + if len(d.pendingUpdates) == 0 { + d.coalesceMu.Unlock() + return nil + } + // Swap out the map so callers can keep writing while we flush. + snapshot := d.pendingUpdates + d.pendingUpdates = make(map[uuid.UUID]pendingUpdate) + d.coalesceMu.Unlock() + + return d.exec(func(tx *sql.Tx) error { + for id, upd := range snapshot { + if err := applyCharacterUpdate(tx, id, upd); err != nil { + return fmt.Errorf("flush update for %s: %w", id, err) + } + } + return nil + }) +} + +// migrate creates the schema on first run. Queries are idempotent (IF NOT EXISTS). +// When moving to Postgres: swap TEXT for UUID, DATETIME for TIMESTAMPTZ, +// AUTOINCREMENT for GENERATED ALWAYS AS IDENTITY, and ? for $N placeholders. +func migrate(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + steam_id TEXT REFERENCES users(id), + slot INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + expires_at DATETIME, -- populated on soft-delete for GC + data_created_at DATETIME, + data_size INTEGER NOT NULL DEFAULT 0, + data_payload TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS deleted_characters ( + steam_id TEXT NOT NULL REFERENCES users(id), + slot INTEGER NOT NULL, + character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + deleted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (steam_id, slot) + UNIQUE (character_id) + ); + + -- Stores the version history (Versions []CharacterData on the schema struct). + -- Ordered by autoincrement id to preserve insertion order. + CREATE TABLE IF NOT EXISTS character_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + created_at DATETIME NOT NULL, + size INTEGER NOT NULL, + data_payload TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_chars_steam_id ON characters(steam_id); + CREATE INDEX IF NOT EXISTS idx_charver_char_id ON character_versions(character_id); + `) + return err +} diff --git a/internal/database/sqlite/sqlite_test.go b/internal/database/sqlite/sqlite_test.go new file mode 100644 index 0000000..90e7569 --- /dev/null +++ b/internal/database/sqlite/sqlite_test.go @@ -0,0 +1,663 @@ +package sqlite + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/msrevive/nexus2/internal/bitmask" + "github.com/msrevive/nexus2/internal/database" + //"github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestDB creates a fresh in-memory SQLite database for each test. +// Using a unique URI per test prevents cross-test contamination while +// still exercising the real schema migration and write worker. +func newTestDB(t *testing.T) *sqliteDB { + t.Helper() + db := New() + cfg := database.Config{} + cfg.SQLite.Path = ":memory:" + require.NoError(t, db.Connect(cfg, database.Options{})) + t.Cleanup(func() { _ = db.Disconnect() }) + return db +} + +// seedUser inserts a user row directly via NewCharacter (which upserts the user) +// or via SetUserFlags after a character has been created. For tests that only +// need a user without characters we create a throwaway character then delete it. +func seedUser(t *testing.T, db *sqliteDB, steamid string) { + t.Helper() + _, err := db.NewCharacter(steamid, 0, 1, "seed") + require.NoError(t, err) +} + +// seedCharacter creates a character and returns its ID. +func seedCharacter(t *testing.T, db *sqliteDB, steamid string, slot, size int, data string) uuid.UUID { + t.Helper() + id, err := db.NewCharacter(steamid, slot, size, data) + require.NoError(t, err) + return id +} + +// flush waits long enough for the coalescing flush worker to commit any +// pending UpdateCharacter calls (default interval is 500 ms). +func flush(t *testing.T, db *sqliteDB) { + t.Helper() + require.NoError(t, db.RunGC()) // RunGC always calls flushPendingUpdates +} + +// ─── Connect / Disconnect ──────────────────────────────────────────────────── + +func TestConnect_CreatesSchema(t *testing.T) { + // If migrate fails the Connect call itself returns an error. + db := newTestDB(t) + assert.NotNil(t, db) +} + +// ─── User tests ────────────────────────────────────────────────────────────── + +func TestGetAllUsers_Empty(t *testing.T) { + db := newTestDB(t) + users, err := db.GetAllUsers() + require.NoError(t, err) + assert.Empty(t, users) +} + +func TestGetAllUsers_ReturnsAllUsers(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + seedUser(t, db, "steam2") + + users, err := db.GetAllUsers() + require.NoError(t, err) + assert.Len(t, users, 2) + + ids := make([]string, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + assert.ElementsMatch(t, []string{"steam1", "steam2"}, ids) +} + +func TestGetUser_Found(t *testing.T) { + db := newTestDB(t) + charID := seedCharacter(t, db, "steam1", 0, 100, "data") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, "steam1", u.ID) + assert.Equal(t, charID, u.Characters[0]) +} + +func TestGetUser_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetUser("nobody") + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUser_LoadsDeletedCharacters(t *testing.T) { + db := newTestDB(t) + charID := seedCharacter(t, db, "steam1", 0, 100, "data") + require.NoError(t, db.SoftDeleteCharacter(charID, 24*time.Hour)) + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, charID, u.DeletedCharacters[0]) + assert.Empty(t, u.Characters) // no longer in the active map +} + +// ─── User flag tests ───────────────────────────────────────────────────────── + +func TestSetAndGetUserFlags(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + flags := bitmask.Bitmask(0b1010) + require.NoError(t, db.SetUserFlags("steam1", flags)) + + got, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, flags, got) +} + +func TestSetUserFlags_UserNotFound(t *testing.T) { + db := newTestDB(t) + err := db.SetUserFlags("ghost", bitmask.Bitmask(1)) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUserFlags_UserNotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetUserFlags("ghost") + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetUserFlags_DefaultZero(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + flags, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, bitmask.Bitmask(0), flags) +} + +func TestSetUserFlags_Overwrite(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + require.NoError(t, db.SetUserFlags("steam1", bitmask.Bitmask(0xFF))) + require.NoError(t, db.SetUserFlags("steam1", bitmask.Bitmask(0x01))) + + flags, err := db.GetUserFlags("steam1") + require.NoError(t, err) + assert.Equal(t, bitmask.Bitmask(0x01), flags) +} + +// ─── NewCharacter ───────────────────────────────────────────────────────────── + +func TestNewCharacter_ReturnsUniqueIDs(t *testing.T) { + db := newTestDB(t) + id1 := seedCharacter(t, db, "steam1", 0, 100, "a") + id2 := seedCharacter(t, db, "steam1", 1, 100, "b") + assert.NotEqual(t, id1, id2) +} + +func TestNewCharacter_CreatesUserIfMissing(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "newuser", 0, 10, "x") + + u, err := db.GetUser("newuser") + require.NoError(t, err) + assert.Equal(t, "newuser", u.ID) +} + +func TestNewCharacter_Idempotent_UserUpsert(t *testing.T) { + db := newTestDB(t) + // Two characters for the same user should not violate a UNIQUE constraint + // on the users table. + seedCharacter(t, db, "steam1", 0, 10, "a") + seedCharacter(t, db, "steam1", 1, 20, "b") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Len(t, u.Characters, 2) +} + +// ─── GetCharacter ───────────────────────────────────────────────────────────── + +func TestGetCharacter_Found(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 42, "mydata") + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, id, c.ID) + assert.Equal(t, "steam1", c.SteamID) + assert.Equal(t, 0, c.Slot) + assert.Equal(t, 42, c.Data.Size) + assert.Equal(t, "mydata", c.Data.Data) + assert.Nil(t, c.DeletedAt) +} + +func TestGetCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.GetCharacter(uuid.New()) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestGetCharacter_HasNoVersionsInitially(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Empty(t, c.Versions) +} + +// ─── GetCharacters ──────────────────────────────────────────────────────────── + +func TestGetCharacters_ReturnsActiveOnly(t *testing.T) { + db := newTestDB(t) + id0 := seedCharacter(t, db, "steam1", 0, 10, "slot0") + id1 := seedCharacter(t, db, "steam1", 1, 20, "slot1") + + // Soft-delete slot 1 — it should NOT appear in GetCharacters. + require.NoError(t, db.SoftDeleteCharacter(id1, time.Hour)) + + chars, err := db.GetCharacters("steam1") + require.NoError(t, err) + assert.Len(t, chars, 1) + assert.Equal(t, id0, chars[0].ID) +} + +func TestGetCharacters_Empty(t *testing.T) { + db := newTestDB(t) + chars, err := db.GetCharacters("nobody") + require.NoError(t, err) + assert.Empty(t, chars) +} + +func TestGetCharacters_KeyedBySlot(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "steam1", 3, 10, "three") + seedCharacter(t, db, "steam1", 7, 20, "seven") + + chars, err := db.GetCharacters("steam1") + require.NoError(t, err) + assert.Equal(t, "three", chars[3].Data.Data) + assert.Equal(t, "seven", chars[7].Data.Data) +} + +// ─── LookUpCharacterID ─────────────────────────────────────────────────────── + +func TestLookUpCharacterID_Found(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 2, 10, "data") + + got, err := db.LookUpCharacterID("steam1", 2) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestLookUpCharacterID_NotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.LookUpCharacterID("steam1", 99) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestLookUpCharacterID_IgnoresSoftDeleted(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + _, err := db.LookUpCharacterID("steam1", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── UpdateCharacter ───────────────────────────────────────────────────────── + +func TestUpdateCharacter_CoalescedFlush(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "original") + + // Two back-to-back updates — only the last should persist. + require.NoError(t, db.UpdateCharacter(id, 20, "second", 0, 0)) + require.NoError(t, db.UpdateCharacter(id, 30, "third", 0, 0)) + + flush(t, db) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, 30, c.Data.Size) + assert.Equal(t, "third", c.Data.Data) +} + +func TestUpdateCharacter_CreatesFirstVersion(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "v0") + + require.NoError(t, db.UpdateCharacter(id, 20, "v1", 5, 0)) + flush(t, db) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Len(t, c.Versions, 1) + assert.Equal(t, "v0", c.Versions[0].Data) +} + +func TestUpdateCharacter_RespectsBackupMax(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "init") + + // With backupMax=2 and backupTime=0 (always snapshot), after 3 updates + // there should be at most 2 versions. + for i, payload := range []string{"a", "b", "c"} { + require.NoError(t, db.UpdateCharacter(id, i+1, payload, 2, 0)) + flush(t, db) + } + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.LessOrEqual(t, len(c.Versions), 2) +} + +// ─── SoftDeleteCharacter / RestoreCharacter ─────────────────────────────────── + +func TestSoftDeleteCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.SoftDeleteCharacter(id, 24*time.Hour)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotNil(t, c.DeletedAt, "deleted_at should be set after soft delete") +} + +func TestSoftDeleteCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + err := db.SoftDeleteCharacter(uuid.New(), time.Hour) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestSoftDeleteCharacter_AppearsInDeletedCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + u, err := db.GetUser("steam1") + require.NoError(t, err) + assert.Equal(t, id, u.DeletedCharacters[0]) +} + +func TestRestoreCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + require.NoError(t, db.SoftDeleteCharacter(id, time.Hour)) + + require.NoError(t, db.RestoreCharacter(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Nil(t, c.DeletedAt) + + // Should reappear in active characters. + got, err := db.LookUpCharacterID("steam1", 0) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestRestoreCharacter_NotFound(t *testing.T) { + db := newTestDB(t) + err := db.RestoreCharacter(uuid.New()) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── DeleteCharacter ───────────────────────────────────────────────────────── + +func TestDeleteCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.DeleteCharacter(id)) + + _, err := db.GetCharacter(id) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── DeleteCharacterReference ───────────────────────────────────────────────── + +func TestDeleteCharacterReference_RemovesActiveSlot(t *testing.T) { + db := newTestDB(t) + seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.DeleteCharacterReference("steam1", 0)) + + _, err := db.LookUpCharacterID("steam1", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestDeleteCharacterReference_NoopWhenMissing(t *testing.T) { + db := newTestDB(t) + // Deleting a reference that doesn't exist should not return an error. + assert.NoError(t, db.DeleteCharacterReference("nobody", 99)) +} + +// ─── MoveCharacter ──────────────────────────────────────────────────────────── + +func TestMoveCharacter(t *testing.T) { + db := newTestDB(t) + // Both users must exist; MoveCharacter checks for the target user. + id := seedCharacter(t, db, "steam1", 0, 10, "data") + seedUser(t, db, "steam2") + + require.NoError(t, db.MoveCharacter(id, "steam2", 3)) + + // Character now belongs to steam2 slot 3. + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "steam2", c.SteamID) + assert.Equal(t, 3, c.Slot) + + // Old slot on steam1 should be gone. + _, err = db.LookUpCharacterID("steam1", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) + + // New slot on steam2 should resolve. + got, err := db.LookUpCharacterID("steam2", 3) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestMoveCharacter_CharacterNotFound(t *testing.T) { + db := newTestDB(t) + err := db.MoveCharacter(uuid.New(), "steam2", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestMoveCharacter_TargetUserNotFound(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + err := db.MoveCharacter(id, "ghost", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── CopyCharacter ──────────────────────────────────────────────────────────── + +func TestCopyCharacter(t *testing.T) { + db := newTestDB(t) + origID := seedCharacter(t, db, "steam1", 0, 42, "original") + + newID, err := db.CopyCharacter(origID, "steam2", 1) + require.NoError(t, err) + assert.NotEqual(t, origID, newID) + + // Original unchanged. + orig, err := db.GetCharacter(origID) + require.NoError(t, err) + assert.Equal(t, "steam1", orig.SteamID) + + // Copy has correct owner and payload. + copy, err := db.GetCharacter(newID) + require.NoError(t, err) + assert.Equal(t, "steam2", copy.SteamID) + assert.Equal(t, 1, copy.Slot) + assert.Equal(t, "original", copy.Data.Data) + assert.Equal(t, 42, copy.Data.Size) +} + +func TestCopyCharacter_CreatesTargetUserIfMissing(t *testing.T) { + db := newTestDB(t) + origID := seedCharacter(t, db, "steam1", 0, 10, "data") + + _, err := db.CopyCharacter(origID, "brandnew", 0) + require.NoError(t, err) + + u, err := db.GetUser("brandnew") + require.NoError(t, err) + assert.Equal(t, "brandnew", u.ID) +} + +func TestCopyCharacter_OriginalNotFound(t *testing.T) { + db := newTestDB(t) + _, err := db.CopyCharacter(uuid.New(), "steam2", 0) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +// ─── RollbackCharacter ──────────────────────────────────────────────────────── + +func TestRollbackCharacter(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + // Create a version by updating once (backupMax>0, backupTime=0 means always snapshot). + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + + // Rollback to version index 0 (the "v0" snapshot). + require.NoError(t, db.RollbackCharacter(id, 0)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "v0", c.Data.Data) + assert.Equal(t, 1, c.Data.Size) +} + +func TestRollbackCharacter_InvalidIndex(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + err := db.RollbackCharacter(id, 99) + assert.Error(t, err) +} + +func TestRollbackCharacterToLatest(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + require.NoError(t, db.UpdateCharacter(id, 3, "v2", 5, 0)) + flush(t, db) + + // Manually clobber the current data to simulate corruption. + require.NoError(t, db.UpdateCharacter(id, 0, "corrupt", 0, 0)) + flush(t, db) + + require.NoError(t, db.RollbackCharacterToLatest(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + // Should have rolled back to the latest version (v2, since backupMax was 5). + assert.NotEqual(t, "corrupt", c.Data.Data) +} + +func TestRollbackCharacterToLatest_NoVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + err := db.RollbackCharacterToLatest(id) + assert.Error(t, err) +} + +// ─── DeleteCharacterVersions ───────────────────────────────────────────────── + +func TestDeleteCharacterVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "v0") + + require.NoError(t, db.UpdateCharacter(id, 2, "v1", 5, 0)) + flush(t, db) + + require.NoError(t, db.DeleteCharacterVersions(id)) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Empty(t, c.Versions) +} + +func TestDeleteCharacterVersions_NoVersions(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "data") + // Should succeed even if there are no versions to delete. + assert.NoError(t, db.DeleteCharacterVersions(id)) +} + +// ─── SyncToDisk / RunGC ────────────────────────────────────────────────────── + +func TestSyncToDisk(t *testing.T) { + db := newTestDB(t) + assert.NoError(t, db.SyncToDisk()) +} + +func TestRunGC_PurgesExpiredCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + // Use a negative duration so expires_at is already in the past. + require.NoError(t, db.SoftDeleteCharacter(id, -1*time.Second)) + + require.NoError(t, db.RunGC()) + + _, err := db.GetCharacter(id) + assert.ErrorIs(t, err, database.ErrNoDocument) +} + +func TestRunGC_KeepsNonExpiredCharacters(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 10, "data") + + require.NoError(t, db.SoftDeleteCharacter(id, 24*time.Hour)) + require.NoError(t, db.RunGC()) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotNil(t, c) +} + +func TestRunGC_FlushesBeforeGC(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "old") + + require.NoError(t, db.UpdateCharacter(id, 99, "new", 0, 0)) + // RunGC should flush the pending update before running the GC query. + require.NoError(t, db.RunGC()) + + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.Equal(t, "new", c.Data.Data) +} + +// ─── schema.User integrity ─────────────────────────────────────────────────── + +func TestGetUser_CharacterMapsAreInitialized(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + u, err := db.GetUser("steam1") + require.NoError(t, err) + // Maps must not be nil so callers can safely do map[key] lookups. + assert.NotNil(t, u.Characters) + assert.NotNil(t, u.DeletedCharacters) +} + +func TestGetAllUsers_CharacterMapsAreInitialized(t *testing.T) { + db := newTestDB(t) + seedUser(t, db, "steam1") + + users, err := db.GetAllUsers() + require.NoError(t, err) + require.Len(t, users, 1) + assert.NotNil(t, users[0].Characters) + assert.NotNil(t, users[0].DeletedCharacters) +} + +// ─── Concurrency / coalescing sanity check ─────────────────────────────────── + +func TestUpdateCharacter_ConcurrentUpdatesCoalesce(t *testing.T) { + db := newTestDB(t) + id := seedCharacter(t, db, "steam1", 0, 1, "init") + + const workers = 20 + done := make(chan struct{}) + for i := 0; i < workers; i++ { + i := i + go func() { + _ = db.UpdateCharacter(id, i, fmt.Sprintf("payload-%d", i), 0, 0) + done <- struct{}{} + }() + } + for i := 0; i < workers; i++ { + <-done + } + + flush(t, db) + + // We don't care which payload won — just that the DB is consistent. + c, err := db.GetCharacter(id) + require.NoError(t, err) + assert.NotEmpty(t, c.Data.Data) +} \ No newline at end of file diff --git a/internal/database/sqlite/users.go b/internal/database/sqlite/users.go new file mode 100644 index 0000000..6a64989 --- /dev/null +++ b/internal/database/sqlite/users.go @@ -0,0 +1,181 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "github.com/msrevive/nexus2/internal/bitmask" + "github.com/msrevive/nexus2/internal/database" + "github.com/msrevive/nexus2/pkg/database/schema" + + "github.com/google/uuid" +) + +func (d *sqliteDB) GetAllUsers() ([]*schema.User, error) { + rows, err := d.db.Query(` + SELECT + u.id, u.revision, u.flags, + c.slot AS char_slot, + c.id AS char_id, + dc.slot AS del_slot, + dc.character_id AS del_char_id + FROM users u + LEFT JOIN characters c + ON c.steam_id = u.id AND c.deleted_at IS NULL + LEFT JOIN deleted_characters dc + ON dc.steam_id = u.id + ORDER BY u.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + userMap := make(map[string]*schema.User) + var order []string + + for rows.Next() { + var ( + id string + revision int + flags uint32 + charSlot *int + charID *string + delSlot *int + delID *string + ) + if err := rows.Scan(&id, &revision, &flags, &charSlot, &charID, &delSlot, &delID); err != nil { + return nil, err + } + + u, exists := userMap[id] + if !exists { + u = &schema.User{ + ID: id, + Revision: revision, + Flags: flags, + Characters: make(map[int]uuid.UUID), + DeletedCharacters: make(map[int]uuid.UUID), + } + userMap[id] = u + order = append(order, id) + } + + if charSlot != nil && charID != nil { + parsed, err := uuid.Parse(*charID) + if err != nil { + return nil, fmt.Errorf("bad character uuid %q: %w", *charID, err) + } + u.Characters[*charSlot] = parsed + } + if delSlot != nil && delID != nil { + parsed, err := uuid.Parse(*delID) + if err != nil { + return nil, fmt.Errorf("bad deleted character uuid %q: %w", *delID, err) + } + u.DeletedCharacters[*delSlot] = parsed + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + users := make([]*schema.User, 0, len(order)) + for _, id := range order { + users = append(users, userMap[id]) + } + return users, nil +} + +func (d *sqliteDB) GetUser(steamid string) (*schema.User, error) { + rows, err := d.db.Query(` + SELECT + u.id, u.revision, u.flags, + c.slot AS char_slot, + c.id AS char_id, + dc.slot AS del_slot, + dc.character_id AS del_char_id + FROM users u + LEFT JOIN characters c + ON c.steam_id = u.id AND c.deleted_at IS NULL + LEFT JOIN deleted_characters dc + ON dc.steam_id = u.id + WHERE u.id = ?`, + steamid, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var u *schema.User + for rows.Next() { + var ( + id string + revision int + flags uint32 + charSlot *int + charID *string + delSlot *int + delID *string + ) + if err := rows.Scan(&id, &revision, &flags, &charSlot, &charID, &delSlot, &delID); err != nil { + return nil, err + } + + if u == nil { + u = &schema.User{ + ID: id, + Revision: revision, + Flags: flags, + Characters: make(map[int]uuid.UUID), + DeletedCharacters: make(map[int]uuid.UUID), + } + } + + if charSlot != nil && charID != nil { + parsed, err := uuid.Parse(*charID) + if err != nil { + return nil, fmt.Errorf("bad character uuid %q: %w", *charID, err) + } + u.Characters[*charSlot] = parsed + } + if delSlot != nil && delID != nil { + parsed, err := uuid.Parse(*delID) + if err != nil { + return nil, fmt.Errorf("bad deleted character uuid %q: %w", *delID, err) + } + u.DeletedCharacters[*delSlot] = parsed + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + if u == nil { + return nil, database.ErrNoDocument + } + return u, nil +} + +func (d *sqliteDB) SetUserFlags(steamid string, flags bitmask.Bitmask) error { + return d.exec(func(tx *sql.Tx) error { + res, err := tx.Exec(`UPDATE users SET flags = ? WHERE id = ?`, uint32(flags), steamid) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return database.ErrNoDocument + } + return nil + }) +} + +func (d *sqliteDB) GetUserFlags(steamid string) (bitmask.Bitmask, error) { + var flags uint32 + err := d.db.QueryRow(`SELECT flags FROM users WHERE id = ?`, steamid).Scan(&flags) + if err == sql.ErrNoRows { + return 0, database.ErrNoDocument + } + return bitmask.Bitmask(flags), err +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f6fd702..01b65a9 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -33,10 +33,12 @@ func (mw *Middleware) ExternalAuth(next http.Handler) http.Handler { ok = true } - if !ok || (key != "" && val != key) { - mw.logger.Info("ExternalAuth: IP is not authorized!", "ip", ip) - http.Error(w, http.StatusText(401), http.StatusUnauthorized) - return + if mw.config.ApiAuth.EnforceIP { + if !ok || (key != "" && val != key) { + mw.logger.Info("ExternalAuth: IP is not authorized!", "ip", ip) + http.Error(w, http.StatusText(401), http.StatusUnauthorized) + return + } } next.ServeHTTP(w, r) diff --git a/internal/migration/migration.go b/internal/migration/migration.go new file mode 100644 index 0000000..7186ea1 --- /dev/null +++ b/internal/migration/migration.go @@ -0,0 +1,150 @@ +package migration + +import ( + "fmt" + "log" + + "github.com/msrevive/nexus2/pkg/database/schema" + "github.com/msrevive/nexus2/internal/database" +) + +// Migrator moves all data from src to dst using only the Database interface. +// Because it operates purely through the interface, it works for any combination +// of backends: Pebble → SQLite, SQLite → Postgres, Mongo → SQLite, etc. +type Migrator struct { + src database.Database + dst database.Database + + // OnProgress is called after each character is migrated so the caller can + // print progress or update a UI. Optional — leave nil to skip. + OnProgress func(steamID string, slot int, charID string) +} + +func New(src, dst database.Database) *Migrator { + return &Migrator{src: src, dst: dst} +} + +// Run performs the full migration in one pass: +// 1. Reads all users from src +// 2. For each user, reads every active and deleted character +// 3. Writes users, characters, versions, flags, and soft-delete state to dst +// +// The destination database must be connected and empty before calling Run. +// Run does not disconnect either database — the caller is responsible for that. +func (m *Migrator) Run() error { + users, err := m.src.GetAllUsers() + if err != nil { + return fmt.Errorf("migration: fetch users: %w", err) + } + + log.Printf("migration: found %d users to migrate", len(users)) + + for _, user := range users { + if err := m.migrateUser(user); err != nil { + return fmt.Errorf("migration: user %s: %w", user.ID, err) + } + } + + // Final sync so everything is durably written before the caller disconnects. + if err := m.dst.SyncToDisk(); err != nil { + return fmt.Errorf("migration: final sync: %w", err) + } + + log.Printf("migration: complete") + return nil +} + +func (m *Migrator) migrateUser(user *schema.User) error { + log.Printf("migration: migrating user %s (%d active, %d deleted characters)", + user.ID, len(user.Characters), len(user.DeletedCharacters)) + + // Migrate active characters first. + for slot, charID := range user.Characters { + char, err := m.src.GetCharacter(charID) + if err != nil { + return fmt.Errorf("get character %s (slot %d): %w", charID, slot, err) + } + + if err := m.migrateCharacter(user, char); err != nil { + return fmt.Errorf("migrate character %s (slot %d): %w", charID, slot, err) + } + + if m.OnProgress != nil { + m.OnProgress(user.ID, slot, charID.String()) + } + } + + // Migrate user flags last so the user row definitely exists in dst. + flags, err := m.src.GetUserFlags(user.ID) + if err != nil { + return fmt.Errorf("get flags for user %s: %w", user.ID, err) + } + if flags != 0 { + if err := m.dst.SetUserFlags(user.ID, flags); err != nil { + return fmt.Errorf("set flags for user %s: %w", user.ID, err) + } + } + + return nil +} + +// migrateCharacter writes a single character and all of its versions to dst. +// It uses NewCharacter to create the initial row and then replays each version +// through UpdateCharacter so that the version history is preserved in order. +func (m *Migrator) migrateCharacter(user *schema.User, char *schema.Character) error { + // NewCharacter creates the user row if it doesn't exist yet, so we don't + // need a separate "create user" step. + newID, err := m.dst.NewCharacter( + char.SteamID, + char.Slot, + char.Data.Size, + char.Data.Data, + ) + if err != nil { + return fmt.Errorf("create character: %w", err) + } + + // The destination assigned a new UUID. If the source UUID matters for + // external references you'll need an ID-mapping strategy — but for most + // game server setups, the slot lookup path (steamid + slot) is what callers + // actually use, so the new UUID is fine. + if newID != char.ID { + log.Printf("migration: character %s re-assigned as %s (slot %d, user %s)", + char.ID, newID, char.Slot, char.SteamID) + } + + // Replay version history oldest-first. We pass backupMax=len(versions)+1 + // so no versions are pruned during the replay, and backupTime=0 so the + // time-gap check is always satisfied. + for _, ver := range char.Versions { + if err := m.dst.UpdateCharacter( + newID, + ver.Size, + ver.Data, + len(char.Versions)+1, // never prune during migration + 0, // no time gap required + ); err != nil { + return fmt.Errorf("replay version: %w", err) + } + } + + // If there were versions, flush them and then restore the current data so + // the active data_payload reflects char.Data and not the last version entry. + if len(char.Versions) > 0 { + if err := m.dst.SyncToDisk(); err != nil { + return fmt.Errorf("sync after version replay: %w", err) + } + // Write the real current data as a final update on top of the versions. + if err := m.dst.UpdateCharacter( + newID, + char.Data.Size, + char.Data.Data, + 0, // backupMax=0 so this update creates no new version entry + 0, + ); err != nil { + return fmt.Errorf("restore current data: %w", err) + } + } + + return nil +} diff --git a/internal/service/character.go b/internal/service/character.go index 6c59498..a090438 100644 --- a/internal/service/character.go +++ b/internal/service/character.go @@ -1,10 +1,9 @@ package service import ( - "fmt" - "github.com/msrevive/nexus2/internal/bitmask" "github.com/msrevive/nexus2/internal/payload" + "github.com/msrevive/nexus2/internal/static" "github.com/msrevive/nexus2/pkg/database/schema" "github.com/msrevive/nexus2/pkg/utils" @@ -44,7 +43,7 @@ func (s *Service) GetCharacterByID(uuid uuid.UUID) (*schema.Character, error) { } if (schema.CharacterData{}) == char.Data { - return nil, fmt.Errorf("malformed character data") + return nil, static.ErrBadCharacterData } return char, nil @@ -141,14 +140,15 @@ func (s *Service) HardDeleteCharacter(uid uuid.UUID) error { return nil } - char, err := s.db.GetCharacter(uid); + // make sure character exists + _, err := s.db.GetCharacter(uid); if err != nil { return err } - if err := s.db.DeleteCharacterReference(char.SteamID, char.Slot); err != nil { - return err - } + // if err := s.db.DeleteCharacterReference(char.SteamID, char.Slot); err != nil { + // return err + // } if err := s.db.DeleteCharacter(uid); err != nil { return err diff --git a/internal/service/rollback.go b/internal/service/rollback.go index b4c8c97..d817bde 100644 --- a/internal/service/rollback.go +++ b/internal/service/rollback.go @@ -1,9 +1,8 @@ package service import ( - "fmt" - "github.com/msrevive/nexus2/pkg/database/schema" + "github.com/msrevive/nexus2/internal/static" "github.com/google/uuid" ) @@ -25,7 +24,7 @@ func (s *Service) GetCharacterVersions(uid uuid.UUID) (map[int]schema.CharacterD return datas, nil } - return nil, fmt.Errorf("no character versions exist") + return nil, static.ErrNoCharacterVersions } func (s *Service) RollbackCharacter(uid uuid.UUID, ver int) error { diff --git a/internal/static/static.go b/internal/static/static.go index 871cfb1..fb0ea44 100644 --- a/internal/static/static.go +++ b/internal/static/static.go @@ -2,6 +2,7 @@ package static import ( "runtime" + "errors" ) const ( @@ -10,8 +11,13 @@ const ( ) var ( - Version = "canary" + Version = "nightly-canary" GoVersion = runtime.Version() OS = runtime.GOOS OSArch = runtime.GOARCH +) + +var ( + ErrNoCharacterVersions = errors.New("no character versions exist") + ErrBadCharacterData = errors.New("malformed character data") ) \ No newline at end of file diff --git a/pkg/database/schema/db.sql b/pkg/database/schema/db.sql new file mode 100644 index 0000000..8af7336 --- /dev/null +++ b/pkg/database/schema/db.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + steam_id TEXT REFERENCES users(id), + slot INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at DATETIME, + expires_at DATETIME, -- populated on soft-delete for GC + data_created_at DATETIME, + data_size INTEGER NOT NULL DEFAULT 0, + data_payload TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS deleted_characters ( + steam_id TEXT NOT NULL REFERENCES users(id), + slot INTEGER NOT NULL, + character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + deleted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (steam_id, slot) + UNIQUE (character_id) +); + +-- Stores the version history (Versions []CharacterData on the schema struct). +-- Ordered by autoincrement id to preserve insertion order. +CREATE TABLE IF NOT EXISTS character_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + created_at DATETIME NOT NULL, + size INTEGER NOT NULL, + data_payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_chars_steam_id ON characters(steam_id); +CREATE INDEX IF NOT EXISTS idx_charver_char_id ON character_versions(character_id); \ No newline at end of file diff --git a/pkg/database/schema/postgresql.sql b/pkg/database/schema/postgresql.sql new file mode 100644 index 0000000..4aa14f3 --- /dev/null +++ b/pkg/database/schema/postgresql.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS characters ( + id UUID PRIMARY KEY, + steam_id TEXT REFERENCES users(id), + slot INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + data_created_at TIMESTAMPTZ, + data_size INTEGER NOT NULL DEFAULT 0, + data_payload TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS deleted_characters ( + steam_id TEXT NOT NULL REFERENCES users(id), + slot INTEGER NOT NULL, + character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (steam_id, slot), + UNIQUE (character_id) +); + +CREATE TABLE IF NOT EXISTS character_versions ( + id SERIAL PRIMARY KEY, + character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + size INTEGER NOT NULL, + data_payload TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_chars_steam_id ON characters(steam_id); +CREATE INDEX IF NOT EXISTS idx_charver_char_id ON character_versions(character_id); \ No newline at end of file diff --git a/runtime/config.example.yaml b/runtime/config.example.yaml index 8891322..6b0eba9 100644 --- a/runtime/config.example.yaml +++ b/runtime/config.example.yaml @@ -2,17 +2,16 @@ core: address: 127.0.0.1 # The IP the FN server should be on. port: 1337 # The port the FN server should listen on. timeout: 60 # The HTTP failure timeout. - dbtype: "pebble" # The type of database the FN should store characters. + dbtype: "sqlite" # The type of database the FN should store characters. database: - mongodb: - connection: "" # The MongoDB connection link to connect to the MongoDB - bbolt: # A lot slower, but uses less disk space, is a single file, least amount of RAM. - file: ./runtime/game/database/characters.db # The location for database file. - timeout: 0 # The timeout for opening of the database file. - badger: # The fastest option, but uses more diskspace at runtime and the most amount of RAM. - directory: ./runtime/game/database # Where the database should be contained. - pebble: # A little bit slower, but uses less RAM and less disk space than badger. - directory: ./runtime/game/database # Where the database should be contained. + pebble: + directory: "" # Where the database should be contained. + sqlite: # This is the recommended and default database. + path: ./runtime/game/data.db # Where the database should be contained. + postgres: + conn: postgres://user:pass@host:5432/dbname?sslmode=require # Postgres DSN + minconns: 4 # Minimum connections to PostgreSQL server + maxconns: 20 # Maximum connections to PostgreSQL server sync: "/30 * * * *" # How often the database should sync to disk from memory using crontabs garbagecollection: "*/10 * * * *" # How often the database garbage collection should run using crontabs. ratelimit: