From 66fde0a7009da00b988e26eb277af6ec1539c053 Mon Sep 17 00:00:00 2001 From: ae Date: Fri, 28 Mar 2025 01:52:56 +0200 Subject: [PATCH] feat: rewrite db actions using raw schemas and queries (sqlc) --- server/go.mod | 24 ++- server/go.sum | 36 +++- server/migrations/0001_initial.up.sql | 30 --- server/pkg/database/migration.go | 77 -------- server/pkg/database/note.go | 230 ---------------------- server/pkg/database/user.go | 104 ---------- server/pkg/db/db.go | 32 +++ server/pkg/db/models.go | 39 ++++ server/pkg/db/note_versions.sql.go | 132 +++++++++++++ server/pkg/db/notes.sql.go | 105 ++++++++++ server/pkg/db/users.sql.go | 98 +++++++++ server/pkg/migrate/migrate.go | 74 +++++++ server/sql/migrations/0001_initial.up.sql | 37 ++++ server/sql/queries/note_versions.sql | 27 +++ server/sql/queries/notes.sql | 18 ++ server/sql/queries/users.sql | 21 ++ server/sql/sqlc.yaml | 12 ++ 17 files changed, 638 insertions(+), 458 deletions(-) delete mode 100644 server/migrations/0001_initial.up.sql delete mode 100644 server/pkg/database/migration.go delete mode 100644 server/pkg/database/note.go delete mode 100644 server/pkg/database/user.go create mode 100644 server/pkg/db/db.go create mode 100644 server/pkg/db/models.go create mode 100644 server/pkg/db/note_versions.sql.go create mode 100644 server/pkg/db/notes.sql.go create mode 100644 server/pkg/db/users.sql.go create mode 100644 server/pkg/migrate/migrate.go create mode 100644 server/sql/migrations/0001_initial.up.sql create mode 100644 server/sql/queries/note_versions.sql create mode 100644 server/sql/queries/notes.sql create mode 100644 server/sql/queries/users.sql create mode 100644 server/sql/sqlc.yaml diff --git a/server/go.mod b/server/go.mod index ef156e3..74e51ba 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,23 +4,29 @@ go 1.24.1 require ( github.com/caarlos0/env v3.5.0+incompatible + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/jackc/pgx/v5 v5.7.4 github.com/rs/zerolog v1.34.0 - gorm.io/driver/postgres v1.5.11 - gorm.io/gorm v1.25.12 + github.com/wagslane/go-password-validator v0.3.0 + golang.org/x/crypto v0.36.0 ) require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.4 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.4 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/stretchr/testify v1.10.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/sync v0.12.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index 69564de..88d2973 100644 --- a/server/go.sum +++ b/server/go.sum @@ -4,7 +4,17 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= +github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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= @@ -13,10 +23,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc= +github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -30,11 +48,17 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= @@ -50,7 +74,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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= -gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= -gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= -gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/server/migrations/0001_initial.up.sql b/server/migrations/0001_initial.up.sql deleted file mode 100644 index d5b3a52..0000000 --- a/server/migrations/0001_initial.up.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE TABLE notes ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE TABLE note_versions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, - title TEXT NOT NULL, - content TEXT NOT NULL, - version_number INT NOT NULL, - content_hash TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE UNIQUE INDEX idx_note_version_unique ON note_versions (note_id, version_number); -CREATE UNIQUE INDEX idx_users_username ON users(username); -CREATE INDEX idx_note_versions_note ON note_versions(note_id); -CREATE INDEX idx_note_versions_number ON note_versions(version_number DESC); diff --git a/server/pkg/database/migration.go b/server/pkg/database/migration.go deleted file mode 100644 index 5342884..0000000 --- a/server/pkg/database/migration.go +++ /dev/null @@ -1,77 +0,0 @@ -package database - -import ( - "fmt" - "io/fs" - "slices" - "sort" - "strconv" - "strings" - - "gorm.io/gorm" -) - -// Atomically run migrations found from the given filesystem (with the format `*.up.sql`). -// Skips already applied migrations, but fails on malformed or iisconfigured entries. Notably atomic -// execution must be utilized to automatically roll back any partially applied migrations. -func RunMigrations(db *gorm.DB, migrationsFS fs.FS) error { - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY - ) - `).Error; err != nil { - return err - } - - // Query already applied migrations to prevent duplicate execution - var applied []int - if err := tx.Table("schema_migrations").Pluck("version", &applied).Error; err != nil { - return err - } - - migrationFiles, err := fs.Glob(migrationsFS, "*.up.sql") - if err != nil { - return err - } - - for _, f := range sortMigrations(migrationFiles) { - version, err := strconv.Atoi(strings.Split(f, "_")[0]) - if err != nil { - return fmt.Errorf("invalid migration filename: %s", f) - } - - if slices.Contains(applied, version) { - continue - } - - sql, err := fs.ReadFile(migrationsFS, f) - if err != nil { - return err - } - - if err := tx.Exec(string(sql)).Error; err != nil { - return err - } - - if err := tx.Exec(` - INSERT INTO schema_migrations (version) VALUES (?) - `, version).Error; err != nil { - return err - } - } - - return nil - }) -} - -// Sort the given migration files to ascending order based on the filename integer prefix to then -// be executed sequentially. -func sortMigrations(files []string) []string { - sort.Slice(files, func(i, j int) bool { - v1, _ := strconv.Atoi(strings.Split(files[i], "_")[0]) - v2, _ := strconv.Atoi(strings.Split(files[j], "_")[0]) - return v1 < v2 - }) - return files -} diff --git a/server/pkg/database/note.go b/server/pkg/database/note.go deleted file mode 100644 index 9ec1c90..0000000 --- a/server/pkg/database/note.go +++ /dev/null @@ -1,230 +0,0 @@ -package database - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "time" - - "gorm.io/gorm" -) - -var ( - ErrUnauthorizedOrMissing = errors.New("item not found or unauthorized") - ErrVersionNotFound = errors.New("version not found") - ErrDeletionFailed = errors.New("deletion failed") -) - -type Note struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` - UserID string `gorm:"type:uuid;not null;index"` - User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - Versions []NoteVersion `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` -} - -type NoteVersion struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` - NoteID string `gorm:"type:uuid;not null;index"` - Title string `gorm:"type:varchar(255);not null"` - Content string `gorm:"type:text;not null"` // TOAST will automatically compress at >2KB - VersionNumber int `gorm:"not null"` - ContentHash string `gorm:"type:varchar(64);not null;index"` - CreatedAt time.Time `gorm:"autoCreateTime"` -} - -// Helper hook that runs automatically before a new note version is created. It calculates a -// composite SHA256 hash of the title-content combination and looks up thte valid version -// number based on any previous entries in the table. -func (nv *NoteVersion) BeforeCreate(tx *gorm.DB) (err error) { - combined := nv.Title + "\n" + nv.Content - hash := sha256.Sum256([]byte(combined)) - nv.ContentHash = hex.EncodeToString(hash[:]) - - var maxVersion int - err = tx.Model(&NoteVersion{}). - Where("note_id = ?", nv.NoteID). - Select("COALESCE(MAX(version_number), 0)"). - Scan(&maxVersion). - Error - - if err != nil { - return err - } - - nv.VersionNumber = maxVersion + 1 - return nil -} - -// NOTE: User ID should always be injected to the requests by a middleware (and not taken from -// a user input). - -// Create a new note and its initial version. Might return an error if the creation transaction -// fails. On success returns a pointer to the newly created note object. -func CreateNote(db *gorm.DB, userID string, title string, content string) (*Note, error) { - note := Note{ - UserID: userID, - } - - err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(¬e).Error; err != nil { - return err - } - - return tx.Create(&NoteVersion{ - NoteID: note.ID, - Title: title, - Content: content, - }).Error - }) - - return ¬e, err -} - -// Get a list of `limit` notes belonging to the given user and their historical versions (preloaded -// version numbers). -func ListNotes(db *gorm.DB, userID string, page int, limit int) ([]Note, error) { - var notes []Note - offset := (page - 1) * limit - - err := db. - Preload("Versions", func(db *gorm.DB) *gorm.DB { - return db.Order("note_versions.version_number DESC").Limit(1) - }). - Where("user_id = ?", userID). - Order("created_at DESC"). - Offset(offset). - Limit(limit). - Find(¬es). - Error - - return notes, err -} - -// Get a single note with its preloaded version numbers. -func GetNote(db *gorm.DB, userID string, noteID string) (*Note, error) { - var note Note - err := db. - Preload("Versions", func(db *gorm.DB) *gorm.DB { - return db.Order("note_versions.version_number DESC") - }). - Where("id = ? AND user_id = ?", noteID, userID). - First(¬e). - Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrUnauthorizedOrMissing - } - - return ¬e, err -} - -// Get all the linked historical versions of the given note. -func GetNoteVersions(db *gorm.DB, userID string, noteID string, page int, limit int) ([]NoteVersion, error) { - var versions []NoteVersion - - var note Note - if err := db. - Select("id"). - Where("id = ? AND user_id = ?", noteID, userID). - First(¬e). - Error; err != nil { - return nil, ErrUnauthorizedOrMissing - } - - offset := (page - 1) * limit - err := db. - Where("note_id = ?", noteID). - Order("version_number DESC"). - Offset(offset). - Limit(limit). - Find(&versions). - Error - - return versions, err -} - -// Get a specific historical version of the given note. -func GetNoteVersion(db *gorm.DB, userID string, noteID string, versionNumber int) (*NoteVersion, error) { - var version NoteVersion - - // Ownership check via note - var note Note - if err := db. - Select("id"). - Where("id = ? AND user_id = ?", noteID, userID). - First(¬e). - Error; err != nil { - return nil, ErrUnauthorizedOrMissing - } - - err := db. - Where("note_id = ? AND version_number = ?", noteID, versionNumber). - First(&version). - Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrVersionNotFound - } - - return &version, err -} - -// Update an existing note by creating a new version. If the composite SHA256 hash of the new -// version contents matches with an already stored version, the new version will be skipped. -func UpdateNote(db *gorm.DB, userID string, noteID string, newTitle string, newContent string) error { - return db.Transaction(func(tx *gorm.DB) error { - var note Note - if err := tx. - Where("id = ? AND user_id = ?", noteID, userID). - First(¬e). - Error; err != nil { - return ErrUnauthorizedOrMissing - } - - var latest NoteVersion - if err := tx. - Where("note_id = ?", noteID). - Order("version_number DESC"). - First(&latest). - Error; err != nil { - return err - } - - combined := newTitle + "\n" + newContent - newHash := sha256.Sum256([]byte(combined)) - if hex.EncodeToString(newHash[:]) == latest.ContentHash { - return nil // No changes - } - - return tx.Create(&NoteVersion{ - NoteID: noteID, - Title: newTitle, - Content: newContent, - }).Error - }) -} - -// Remove a note and its versions permanently (hard delete). -func DeleteNote(db *gorm.DB, userID string, noteID string) error { - return db.Transaction(func(tx *gorm.DB) error { - var note Note - if err := tx. - Where("id = ? AND user_id = ?", noteID, userID). - First(¬e). - Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrUnauthorizedOrMissing - } - return err - } - - // Delete note (cascades to versions) - if err := tx.Delete(¬e).Error; err != nil { - return ErrDeletionFailed - } - - return nil - }) -} diff --git a/server/pkg/database/user.go b/server/pkg/database/user.go deleted file mode 100644 index a3920a6..0000000 --- a/server/pkg/database/user.go +++ /dev/null @@ -1,104 +0,0 @@ -package database - -import ( - "errors" - "time" - - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -type User struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` - Username string `gorm:"unique;not null;index"` - PasswordHash string `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - Notes []Note `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` -} - -var ( - ErrUserNotFound = errors.New("user not found") - ErrUsernameTaken = errors.New("username is not available") - ErrInvalidCredentials = errors.New("invalid credentials") -) - -// Create a new user using the given credentials (password hashed with `bcrypt`). -func CreateUser(db *gorm.DB, username string, password string) (*User, error) { - // Username availability - var count int64 - db.Model(&User{}).Where("username = ?", username).Count(&count) - if count > 0 { - return nil, ErrUsernameTaken - } - - // Password hashing, max length (72 bytes) validated in the handler layer - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, err - } - - user := User{ - Username: username, - PasswordHash: string(hash), - } - - err = db.Create(&user).Error - return &user, err -} - -// Get a user with their UUID. -func GetUserByID(db *gorm.DB, userID string) (*User, error) { - var user User - err := db.First(&user, "id = ?", userID).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrUserNotFound - } - return &user, err -} - -// Get a user with their username. -func GetUserByUsername(db *gorm.DB, username string) (*User, error) { - var user User - err := db.First(&user, "username = ?", username).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrUserNotFound - } - return &user, err -} - -// Verify the given credentials by fetching the hashed password from the database and comparing it -// with the given one using `bcrypt`. -func VerifyUserCredentials(db *gorm.DB, username string, password string) (*User, error) { - user, err := GetUserByUsername(db, username) - if err != nil { - return nil, err // ErrUserNotFound - } - - err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) - if err != nil { - return nil, ErrInvalidCredentials - } - - return user, nil -} - -// Update the password of the given user (identified with UUID). -func UpdatePassword(db *gorm.DB, userID string, newPassword string) error { - // Once again (length) validation should be done in the handler layer - hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - - return db.Model(&User{}). - Where("id = ?", userID). - Update("password_hash", string(hash)). - Error -} - -// Remove a user and their notes (and notes' versions, hard delete with cascade). -func DeleteUser(db *gorm.DB, userID string) error { - return db.Transaction(func(tx *gorm.DB) error { - return tx.Delete(&User{}, "id = ?", userID).Error - }) -} diff --git a/server/pkg/db/db.go b/server/pkg/db/db.go new file mode 100644 index 0000000..eeee39e --- /dev/null +++ b/server/pkg/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/server/pkg/db/models.go b/server/pkg/db/models.go new file mode 100644 index 0000000..6bb1403 --- /dev/null +++ b/server/pkg/db/models.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 + +package db + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Note struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type NoteVersion struct { + ID pgtype.UUID `json:"id"` + NoteID pgtype.UUID `json:"note_id"` + Title string `json:"title"` + Content string `json:"content"` + VersionNumber int32 `json:"version_number"` + ContentHash string `json:"content_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type SchemaMigration struct { + Version int64 `json:"version"` + AppliedAt pgtype.Timestamptz `json:"applied_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} diff --git a/server/pkg/db/note_versions.sql.go b/server/pkg/db/note_versions.sql.go new file mode 100644 index 0000000..905dc38 --- /dev/null +++ b/server/pkg/db/note_versions.sql.go @@ -0,0 +1,132 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: note_versions.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createNoteVersion = `-- name: CreateNoteVersion :one +INSERT INTO note_versions (note_id, title, content, version_number, content_hash) +VALUES ( + $1, + $2, + $3, + (SELECT COALESCE(MAX(version_number), 0) + 1 FROM note_versions WHERE note_id = $1), + encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex') +) +RETURNING id, note_id, title, content, version_number, content_hash, created_at +` + +type CreateNoteVersionParams struct { + NoteID pgtype.UUID `json:"note_id"` + Title string `json:"title"` + Content string `json:"content"` +} + +func (q *Queries) CreateNoteVersion(ctx context.Context, arg CreateNoteVersionParams) (NoteVersion, error) { + row := q.db.QueryRow(ctx, createNoteVersion, arg.NoteID, arg.Title, arg.Content) + var i NoteVersion + err := row.Scan( + &i.ID, + &i.NoteID, + &i.Title, + &i.Content, + &i.VersionNumber, + &i.ContentHash, + &i.CreatedAt, + ) + return i, err +} + +const findDuplicateContent = `-- name: FindDuplicateContent :one +SELECT EXISTS( + SELECT 1 FROM note_versions + WHERE note_id = $1 + AND content_hash = encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex') +) +` + +type FindDuplicateContentParams struct { + NoteID pgtype.UUID `json:"note_id"` + Column2 []byte `json:"column_2"` + Column3 []byte `json:"column_3"` +} + +func (q *Queries) FindDuplicateContent(ctx context.Context, arg FindDuplicateContentParams) (bool, error) { + row := q.db.QueryRow(ctx, findDuplicateContent, arg.NoteID, arg.Column2, arg.Column3) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const getNoteVersion = `-- name: GetNoteVersion :one +SELECT id, note_id, title, content, version_number, content_hash, created_at FROM note_versions +WHERE note_id = $1 AND version_number = $2 LIMIT 1 +` + +type GetNoteVersionParams struct { + NoteID pgtype.UUID `json:"note_id"` + VersionNumber int32 `json:"version_number"` +} + +func (q *Queries) GetNoteVersion(ctx context.Context, arg GetNoteVersionParams) (NoteVersion, error) { + row := q.db.QueryRow(ctx, getNoteVersion, arg.NoteID, arg.VersionNumber) + var i NoteVersion + err := row.Scan( + &i.ID, + &i.NoteID, + &i.Title, + &i.Content, + &i.VersionNumber, + &i.ContentHash, + &i.CreatedAt, + ) + return i, err +} + +const getNoteVersions = `-- name: GetNoteVersions :many +SELECT id, note_id, title, content, version_number, content_hash, created_at FROM note_versions +WHERE note_id = $1 +ORDER BY version_number DESC +LIMIT $2 OFFSET $3 +` + +type GetNoteVersionsParams struct { + NoteID pgtype.UUID `json:"note_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) GetNoteVersions(ctx context.Context, arg GetNoteVersionsParams) ([]NoteVersion, error) { + rows, err := q.db.Query(ctx, getNoteVersions, arg.NoteID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NoteVersion + for rows.Next() { + var i NoteVersion + if err := rows.Scan( + &i.ID, + &i.NoteID, + &i.Title, + &i.Content, + &i.VersionNumber, + &i.ContentHash, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/notes.sql.go b/server/pkg/db/notes.sql.go new file mode 100644 index 0000000..9a471c3 --- /dev/null +++ b/server/pkg/db/notes.sql.go @@ -0,0 +1,105 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: notes.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createNote = `-- name: CreateNote :one +INSERT INTO notes (user_id) +VALUES ($1) +RETURNING id, user_id, created_at, updated_at +` + +func (q *Queries) CreateNote(ctx context.Context, userID pgtype.UUID) (Note, error) { + row := q.db.QueryRow(ctx, createNote, userID) + var i Note + err := row.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteNote = `-- name: DeleteNote :exec +DELETE FROM notes +WHERE id = $1 AND user_id = $2 +` + +type DeleteNoteParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) DeleteNote(ctx context.Context, arg DeleteNoteParams) error { + _, err := q.db.Exec(ctx, deleteNote, arg.ID, arg.UserID) + return err +} + +const getNote = `-- name: GetNote :one +SELECT id, user_id, created_at, updated_at FROM notes +WHERE id = $1 AND user_id = $2 LIMIT 1 +` + +type GetNoteParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) GetNote(ctx context.Context, arg GetNoteParams) (Note, error) { + row := q.db.QueryRow(ctx, getNote, arg.ID, arg.UserID) + var i Note + err := row.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listNotes = `-- name: ListNotes :many +SELECT id, user_id, created_at, updated_at FROM notes +WHERE user_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3 +` + +type ListNotesParams struct { + UserID pgtype.UUID `json:"user_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]Note, error) { + rows, err := q.db.Query(ctx, listNotes, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Note + for rows.Next() { + var i Note + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/pkg/db/users.sql.go b/server/pkg/db/users.sql.go new file mode 100644 index 0000000..b108422 --- /dev/null +++ b/server/pkg/db/users.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: users.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING id, username, password_hash, created_at, updated_at +` + +type CreateUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password_hash, created_at, updated_at FROM users +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash, created_at, updated_at FROM users +WHERE username = $1 LIMIT 1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updatePassword = `-- name: UpdatePassword :exec +UPDATE users +SET password_hash = $2, updated_at = NOW() +WHERE id = $1 +` + +type UpdatePasswordParams struct { + ID pgtype.UUID `json:"id"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error { + _, err := q.db.Exec(ctx, updatePassword, arg.ID, arg.PasswordHash) + return err +} diff --git a/server/pkg/migrate/migrate.go b/server/pkg/migrate/migrate.go new file mode 100644 index 0000000..20aa35e --- /dev/null +++ b/server/pkg/migrate/migrate.go @@ -0,0 +1,74 @@ +// pkg/migrate/migrate.go +package migrate + +import ( + "context" + "embed" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" +) + +func Run(ctx context.Context, conn *pgx.Conn, migrationsFS embed.FS) error { + // Get already applied migrations + rows, _ := conn.Query(ctx, "SELECT version FROM schema_migrations") + defer rows.Close() + + applied := make(map[int64]bool) + for rows.Next() { + var version int64 + if err := rows.Scan(&version); err != nil { + return err + } + applied[version] = true + } + + files, err := migrationsFS.ReadDir("migrations") + if err != nil { + return err + } + + // Apply the migrations sequentially based on their ordinal number + for _, f := range sortMigrations(files) { + version, err := strconv.ParseInt(strings.Split(f.Name(), "_")[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid migration name: %s", f.Name()) + } + + if applied[version] { + continue + } + + // Run migration + sql, err := migrationsFS.ReadFile("migrations/" + f.Name()) + if err != nil { + return err + } + + if _, err := conn.Exec(ctx, string(sql)); err != nil { + return fmt.Errorf("migration %d failed: %w", version, err) + } + + if _, err := conn.Exec(ctx, + "INSERT INTO schema_migrations (version) VALUES ($1)", version, + ); err != nil { + return err + } + } + + return nil +} + +// Sort the migration files based on their ordinal number prefix. +func sortMigrations(files []fs.DirEntry) []fs.DirEntry { + sort.Slice(files, func(i, j int) bool { + v1, _ := strconv.ParseInt(strings.Split(files[i].Name(), "_")[0], 10, 64) + v2, _ := strconv.ParseInt(strings.Split(files[j].Name(), "_")[0], 10, 64) + return v1 < v2 + }) + return files +} diff --git a/server/sql/migrations/0001_initial.up.sql b/server/sql/migrations/0001_initial.up.sql new file mode 100644 index 0000000..390eced --- /dev/null +++ b/server/sql/migrations/0001_initial.up.sql @@ -0,0 +1,37 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT PRIMARY KEY, + applied_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS note_versions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL, + version_number INT NOT NULL, + content_hash TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_note_version_unique ON note_versions(note_id, version_number); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_note_versions_note ON note_versions(note_id); +CREATE INDEX IF NOT EXISTS idx_note_versions_number ON note_versions(version_number DESC); + diff --git a/server/sql/queries/note_versions.sql b/server/sql/queries/note_versions.sql new file mode 100644 index 0000000..493ded8 --- /dev/null +++ b/server/sql/queries/note_versions.sql @@ -0,0 +1,27 @@ +-- name: CreateNoteVersion :one +INSERT INTO note_versions (note_id, title, content, version_number, content_hash) +VALUES ( + $1, + $2, + $3, + (SELECT COALESCE(MAX(version_number), 0) + 1 FROM note_versions WHERE note_id = $1), + encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex') +) +RETURNING *; + +-- name: GetNoteVersions :many +SELECT * FROM note_versions +WHERE note_id = $1 +ORDER BY version_number DESC +LIMIT $2 OFFSET $3; + +-- name: GetNoteVersion :one +SELECT * FROM note_versions +WHERE note_id = $1 AND version_number = $2 LIMIT 1; + +-- name: FindDuplicateContent :one +SELECT EXISTS( + SELECT 1 FROM note_versions + WHERE note_id = $1 + AND content_hash = encode(sha256($2::bytea || '\n'::bytea || $3::bytea), 'hex') +); diff --git a/server/sql/queries/notes.sql b/server/sql/queries/notes.sql new file mode 100644 index 0000000..de58d34 --- /dev/null +++ b/server/sql/queries/notes.sql @@ -0,0 +1,18 @@ +-- name: CreateNote :one +INSERT INTO notes (user_id) +VALUES ($1) +RETURNING *; + +-- name: GetNote :one +SELECT * FROM notes +WHERE id = $1 AND user_id = $2 LIMIT 1; + +-- name: ListNotes :many +SELECT * FROM notes +WHERE user_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3; + +-- name: DeleteNote :exec +DELETE FROM notes +WHERE id = $1 AND user_id = $2; diff --git a/server/sql/queries/users.sql b/server/sql/queries/users.sql new file mode 100644 index 0000000..736fe91 --- /dev/null +++ b/server/sql/queries/users.sql @@ -0,0 +1,21 @@ +-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING *; + +-- name: GetUserByID :one +SELECT * FROM users +WHERE id = $1 LIMIT 1; + +-- name: GetUserByUsername :one +SELECT * FROM users +WHERE username = $1 LIMIT 1; + +-- name: UpdatePassword :exec +UPDATE users +SET password_hash = $2, updated_at = NOW() +WHERE id = $1; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1; diff --git a/server/sql/sqlc.yaml b/server/sql/sqlc.yaml new file mode 100644 index 0000000..22f8193 --- /dev/null +++ b/server/sql/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" + +sql: + - engine: "postgresql" + queries: "./queries/" + schema: "./migrations/" + gen: + go: + package: "db" + out: "../pkg/db" + sql_package: "pgx/v5" + emit_json_tags: true