aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-13 23:52:41 +0300
committergrm <grm@eyesin.space>2026-09-13 23:52:41 +0300
commitaeb19df4222269c585de55be5568326222df879d (patch)
treee87ee743b73ef7b5b1999e61d1b3ad1e19fe1569 /internal/store
parente671381121a63422f0cf4d5b0842f80109a12d20 (diff)
downloadblogspace-aeb19df4222269c585de55be5568326222df879d.tar.gz
blogspace-aeb19df4222269c585de55be5568326222df879d.tar.bz2
blogspace-aeb19df4222269c585de55be5568326222df879d.zip
Give every blog its own Postgres database
A blog is now a database of its own (blog_<sub>) on the same server: one pg_dump is a complete backup of a blog, one psql restores it, and nothing a blog's queries do can reach another blog's rows. The control database (DATABASE_URL) keeps only users and the blog registry (id, owner, subdomain, db_name); title, tagline and theme move into a one-row settings table next to the content so the dump really is everything. db.Cluster holds the control pool plus small, lazily opened per-blog pools. store.Store (control) hands out a store.BlogStore per blog; every blog_id parameter and column is gone, the database is the scope. Handlers reach it through blogStore(r), which resolveBlog puts in the context next to the blog. Existing data is moved in place by control migration 00006, a Go migration that runs inside the control transaction: it creates and migrates each blog database, copies the rows preserving ids, and marks the registry; 00007 then drops the old tables. Either every blog is moved or the control database is untouched. /media/{id} now serves the host's blog only, so dashboard previews on the root domain use /b/{sub}/media/{id}. Subdomains are capped at 58 chars so "blog_" + name fits a Postgres identifier. Deleting a user drops their database. Store.Open resets a blog's pool and retries once so a database restored under a running app (dropdb --force, createdb, psql) just works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/blogs.go118
-rw-r--r--internal/store/images.go25
-rw-r--r--internal/store/menu.go54
-rw-r--r--internal/store/modules.go57
-rw-r--r--internal/store/pages.go69
-rw-r--r--internal/store/posts.go58
-rw-r--r--internal/store/sections.go51
-rw-r--r--internal/store/store.go39
-rw-r--r--internal/store/users.go22
9 files changed, 299 insertions, 194 deletions
diff --git a/internal/store/blogs.go b/internal/store/blogs.go
index b7edee0..a2e88a8 100644
--- a/internal/store/blogs.go
+++ b/internal/store/blogs.go
@@ -3,34 +3,52 @@ package store
import (
"context"
"encoding/json"
+ "errors"
+ "fmt"
"time"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
)
+// Blog is a registry row (control database) plus the settings row of the
+// blog's own database, which Store.Open fills in.
type Blog struct {
ID int64
OwnerID int64
Subdomain string
+ DBName string
+ CreatedAt time.Time
+ // from the blog database
Title string
Tagline string
ThemeJSON json.RawMessage
- CreatedAt time.Time
UpdatedAt time.Time
}
-const blogCols = `id, owner_id, subdomain, title, tagline, theme, created_at, updated_at`
+const blogCols = `id, owner_id, subdomain, db_name, created_at`
func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) {
var b Blog
- err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.Title, &b.Tagline, &b.ThemeJSON, &b.CreatedAt, &b.UpdatedAt)
+ err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &b, nil
}
-// CreateBlogger creates a user, their blog and a default home page in one transaction.
+func (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error {
+ err := bs.db.QueryRow(ctx, `SELECT title, tagline, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.ThemeJSON, &b.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404
+ return fmt.Errorf("database %s of blog %q has no settings row (empty or wrong dump?)", b.DBName, b.Subdomain)
+ }
+ if err != nil {
+ return fmt.Errorf("settings of %s: %w", b.DBName, err)
+ }
+ return nil
+}
+
+// CreateBlogger creates a user, their blog and a default home page.
func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
@@ -43,13 +61,10 @@ func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdo
if err != nil {
return nil, nil, err
}
- b, err := createBlog(ctx, tx, u.ID, subdomain, title)
+ b, err := s.createBlog(ctx, tx, u.ID, subdomain, title)
if err != nil {
return nil, nil, err
}
- if err := tx.Commit(ctx); err != nil {
- return nil, nil, err
- }
return u, b, nil
}
@@ -60,30 +75,64 @@ func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title
return nil, err
}
defer tx.Rollback(ctx)
- b, err := createBlog(ctx, tx, ownerID, subdomain, title)
+ return s.createBlog(ctx, tx, ownerID, subdomain, title)
+}
+
+// createBlog registers the blog in tx, then creates and fills its database and
+// commits. The registry insert goes first so a taken subdomain fails before
+// any DDL; if anything after that fails the transaction rolls back and the new
+// database is dropped again.
+func (s *Store) createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
+ name := db.DBName(subdomain)
+ b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, db_name) VALUES ($1,$2,$3) RETURNING `+blogCols,
+ ownerID, subdomain, name))
+ if err != nil {
+ return nil, err
+ }
+ if err := s.cluster.CreateBlogDB(ctx, name); err != nil {
+ return nil, err
+ }
+ bs, err := s.fillNewBlog(ctx, b, title)
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
if err != nil {
+ s.cluster.DropBlogDB(ctx, name)
return nil, err
}
- return b, tx.Commit(ctx)
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ return b, nil
}
-func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
- b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols,
- ownerID, subdomain, title))
+// fillNewBlog writes what every new blog starts with: its settings, a home
+// page in the menu and the default layout.
+func (s *Store) fillNewBlog(ctx context.Context, b *Blog, title string) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
if err != nil {
return nil, err
}
+ bs := &BlogStore{db: pool}
+ btx, err := pool.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer btx.Rollback(ctx)
+ if _, err := btx.Exec(ctx, `INSERT INTO settings (title, created_at, updated_at) VALUES ($1,$2,$2)`, title, b.CreatedAt); err != nil {
+ return nil, err
+ }
var homeID int64
- if err := tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true) RETURNING id`, b.ID).Scan(&homeID); err != nil {
+ if err := btx.QueryRow(ctx, `INSERT INTO pages (slug, title, nav_order, is_home) VALUES ('home','Home',0,true) RETURNING id`).Scan(&homeID); err != nil {
return nil, wrap(err)
}
- if err := addMenuPage(ctx, tx, b.ID, homeID); err != nil {
+ if err := addMenuPage(ctx, btx, homeID); err != nil {
return nil, err
}
- if err := insertDefaultModules(ctx, tx, b.ID); err != nil {
+ if err := insertDefaultModules(ctx, btx); err != nil {
return nil, err
}
- return b, nil
+ return bs, btx.Commit(ctx)
}
func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) {
@@ -98,17 +147,38 @@ func (s *Store) BlogByOwner(ctx context.Context, ownerID int64) (*Blog, error) {
return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE owner_id=$1`, ownerID))
}
-func (s *Store) UpdateBlogSettings(ctx context.Context, id int64, title, tagline string) error {
- _, err := s.db.Exec(ctx, `UPDATE blogs SET title=$2, tagline=$3, updated_at=now() WHERE id=$1`, id, title, tagline)
- return err
+// ListBlogs returns every registered blog, oldest first.
+func (s *Store) ListBlogs(ctx context.Context) ([]Blog, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+blogCols+` FROM blogs ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Blog
+ for rows.Next() {
+ b, err := scanBlog(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *b)
+ }
+ return out, rows.Err()
+}
+
+// DeleteBlog unregisters the blog and drops its database.
+func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error {
+ if _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, b.ID); err != nil {
+ return err
+ }
+ return s.cluster.DropBlogDB(ctx, b.DBName)
}
-func (s *Store) UpdateBlogTheme(ctx context.Context, id int64, theme json.RawMessage) error {
- _, err := s.db.Exec(ctx, `UPDATE blogs SET theme=$2, updated_at=now() WHERE id=$1`, id, theme)
+func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline string) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, updated_at=now()`, title, tagline)
return err
}
-func (s *Store) DeleteBlog(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id)
+func (bs *BlogStore) UpdateTheme(ctx context.Context, theme json.RawMessage) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET theme=$1, updated_at=now()`, theme)
return err
}
diff --git a/internal/store/images.go b/internal/store/images.go
index 3699e75..b222468 100644
--- a/internal/store/images.go
+++ b/internal/store/images.go
@@ -9,7 +9,6 @@ import (
type Image struct {
ID uuid.UUID
- BlogID int64
Filename string
ContentType string
Size int
@@ -17,15 +16,15 @@ type Image struct {
CreatedAt time.Time
}
-func (s *Store) CreateImage(ctx context.Context, blogID int64, filename, contentType string, data []byte) (*Image, error) {
- img := &Image{ID: uuid.New(), BlogID: blogID, Filename: filename, ContentType: contentType, Size: len(data)}
- err := s.db.QueryRow(ctx, `INSERT INTO images (id, blog_id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`,
- img.ID, blogID, filename, contentType, len(data), data).Scan(&img.CreatedAt)
+func (bs *BlogStore) CreateImage(ctx context.Context, filename, contentType string, data []byte) (*Image, error) {
+ img := &Image{ID: uuid.New(), Filename: filename, ContentType: contentType, Size: len(data)}
+ err := bs.db.QueryRow(ctx, `INSERT INTO images (id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5) RETURNING created_at`,
+ img.ID, filename, contentType, len(data), data).Scan(&img.CreatedAt)
return img, err
}
-func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
- rows, err := s.db.Query(ctx, `SELECT id, blog_id, filename, content_type, size, created_at FROM images WHERE blog_id=$1 ORDER BY created_at DESC`, blogID)
+func (bs *BlogStore) ListImages(ctx context.Context) ([]Image, error) {
+ rows, err := bs.db.Query(ctx, `SELECT id, filename, content_type, size, created_at FROM images ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
@@ -33,7 +32,7 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
var out []Image
for rows.Next() {
var i Image
- if err := rows.Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
+ if err := rows.Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
return nil, err
}
out = append(out, i)
@@ -42,17 +41,17 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
}
// ImageData loads an image including its bytes.
-func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
+func (bs *BlogStore) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
var i Image
- err := s.db.QueryRow(ctx, `SELECT id, blog_id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
- Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt)
+ err := bs.db.QueryRow(ctx, `SELECT id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
+ Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &i, nil
}
-func (s *Store) DeleteImage(ctx context.Context, blogID int64, id uuid.UUID) error {
- _, err := s.db.Exec(ctx, `DELETE FROM images WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteImage(ctx context.Context, id uuid.UUID) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM images WHERE id=$1`, id)
return err
}
diff --git a/internal/store/menu.go b/internal/store/menu.go
index 2d3eabd..cedb7ad 100644
--- a/internal/store/menu.go
+++ b/internal/store/menu.go
@@ -2,14 +2,11 @@ package store
import (
"context"
-
- "github.com/jackc/pgx/v5/pgconn"
)
// MenuItem is one entry of a blog's menu: either a page of the blog or a custom link.
type MenuItem struct {
ID int64
- BlogID int64
PageID *int64 // nil for custom links
Label string // custom links only; pages use their title
URL string // custom links only
@@ -39,11 +36,11 @@ func (m MenuItem) Href() string {
return "/" + m.PageSlug
}
-const menuCols = `m.id, m.blog_id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
+const menuCols = `m.id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
-func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) {
- rows, err := s.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
- WHERE m.blog_id=$1 ORDER BY m.sort_order, m.id`, blogID)
+func (bs *BlogStore) ListMenu(ctx context.Context) ([]MenuItem, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
+ ORDER BY m.sort_order, m.id`)
if err != nil {
return nil, err
}
@@ -51,7 +48,7 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
var out []MenuItem
for rows.Next() {
var m MenuItem
- if err := rows.Scan(&m.ID, &m.BlogID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
+ if err := rows.Scan(&m.ID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
return nil, err
}
out = append(out, m)
@@ -60,41 +57,41 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
}
// AddMenuPage appends a page to the menu; a page already in the menu is left where it is.
-func (s *Store) AddMenuPage(ctx context.Context, blogID, pageID int64) error {
- return addMenuPage(ctx, s.db, blogID, pageID)
+func (bs *BlogStore) AddMenuPage(ctx context.Context, pageID int64) error {
+ return addMenuPage(ctx, bs.db, pageID)
}
-func addMenuPage(ctx context.Context, db querier, blogID, pageID int64) error {
- _, err := db.Exec(ctx, `INSERT INTO menu_items (blog_id, page_id, sort_order)
- SELECT $1, id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1)
- FROM pages WHERE blog_id=$1 AND id=$2 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, blogID, pageID)
+func addMenuPage(ctx context.Context, db querier, pageID int64) error {
+ _, err := db.Exec(ctx, `INSERT INTO menu_items (page_id, sort_order)
+ SELECT id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items)
+ FROM pages WHERE id=$1 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, pageID)
return err
}
// SetPageInMenu adds or removes a page's menu entry.
-func (s *Store) SetPageInMenu(ctx context.Context, blogID, pageID int64, on bool) error {
+func (bs *BlogStore) SetPageInMenu(ctx context.Context, pageID int64, on bool) error {
if on {
- return s.AddMenuPage(ctx, blogID, pageID)
+ return bs.AddMenuPage(ctx, pageID)
}
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, blogID, pageID)
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, pageID)
return err
}
// AddMenuLink appends a custom link to the menu.
-func (s *Store) AddMenuLink(ctx context.Context, blogID int64, label, url string) error {
- _, err := s.db.Exec(ctx, `INSERT INTO menu_items (blog_id, label, url, sort_order)
- VALUES ($1,$2,$3,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1))`, blogID, label, url)
+func (bs *BlogStore) AddMenuLink(ctx context.Context, label, url string) error {
+ _, err := bs.db.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order)
+ VALUES ($1,$2,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items))`, label, url)
return err
}
-func (s *Store) DeleteMenuItem(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteMenuItem(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE id=$1`, id)
return err
}
// MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down).
-func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) error {
- items, err := s.ListMenu(ctx, blogID)
+func (bs *BlogStore) MoveMenuItem(ctx context.Context, id int64, dir int) error {
+ items, err := bs.ListMenu(ctx)
if err != nil {
return err
}
@@ -109,20 +106,15 @@ func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) err
return nil
}
items[idx], items[j] = items[j], items[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range items { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
return tx.Commit(ctx)
}
-
-// querier is what the helpers shared with page transactions need: the pool or a pgx.Tx.
-type querier interface {
- Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
-}
diff --git a/internal/store/modules.go b/internal/store/modules.go
index c7d9760..e0c3cd5 100644
--- a/internal/store/modules.go
+++ b/internal/store/modules.go
@@ -9,7 +9,6 @@ import (
// block of custom HTML… placed in one of the areas of the page.
type Module struct {
ID int64
- BlogID int64
Area string // header | left | right | above | below | footer
Kind string // title | logo | menu | archive | recent | html | rss | text | sitemap
Title string // optional heading (side columns and footer)
@@ -20,20 +19,20 @@ type Module struct {
UpdatedAt time.Time
}
-const moduleCols = `id, blog_id, area, kind, title, body, count, sort_order, created_at, updated_at`
+const moduleCols = `id, area, kind, title, body, count, sort_order, created_at, updated_at`
func scanModule(row interface{ Scan(...any) error }) (*Module, error) {
var m Module
- err := row.Scan(&m.ID, &m.BlogID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
+ err := row.Scan(&m.ID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &m, nil
}
-// ListModules returns every module of a blog, grouped by area in display order.
-func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) {
- rows, err := s.db.Query(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 ORDER BY area, sort_order, id`, blogID)
+// ListModules returns every module of the blog, grouped by area in display order.
+func (bs *BlogStore) ListModules(ctx context.Context) ([]Module, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+moduleCols+` FROM modules ORDER BY area, sort_order, id`)
if err != nil {
return nil, err
}
@@ -49,36 +48,36 @@ func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error)
return out, rows.Err()
}
-func (s *Store) ModuleByID(ctx context.Context, blogID, id int64) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) ModuleByID(ctx context.Context, id int64) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE id=$1`, id))
}
// CreateModule appends a module to the end of its area.
-func (s *Store) CreateModule(ctx context.Context, m *Module) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `INSERT INTO modules (blog_id, area, kind, title, body, count, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE blog_id=$1 AND area=$2)) RETURNING `+moduleCols,
- m.BlogID, m.Area, m.Kind, m.Title, m.Body, m.Count))
+func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE area=$1)) RETURNING `+moduleCols,
+ m.Area, m.Kind, m.Title, m.Body, m.Count))
}
// UpdateModule saves the editable settings; area and kind never change.
-func (s *Store) UpdateModule(ctx context.Context, m *Module) error {
- _, err := s.db.Exec(ctx, `UPDATE modules SET title=$3, body=$4, count=$5, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- m.BlogID, m.ID, m.Title, m.Body, m.Count)
+func (bs *BlogStore) UpdateModule(ctx context.Context, m *Module) error {
+ _, err := bs.db.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, updated_at=now() WHERE id=$1`,
+ m.ID, m.Title, m.Body, m.Count)
return wrap(err)
}
-func (s *Store) DeleteModule(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteModule(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM modules WHERE id=$1`, id)
return err
}
// MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down).
-func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error {
- cur, err := s.ModuleByID(ctx, blogID, id)
+func (bs *BlogStore) MoveModule(ctx context.Context, id int64, dir int) error {
+ cur, err := bs.ModuleByID(ctx, id)
if err != nil {
return err
}
- all, err := s.ListModules(ctx, blogID)
+ all, err := bs.ListModules(ctx)
if err != nil {
return err
}
@@ -98,13 +97,13 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
return nil
}
mods[idx], mods[j] = mods[j], mods[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range mods { // renumber the area; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
@@ -112,16 +111,16 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
}
// ResetModules puts the layout back to what a new blog starts with.
-func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) ResetModules(ctx context.Context) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1`, blogID); err != nil {
+ if _, err := tx.Exec(ctx, `DELETE FROM modules`); err != nil {
return err
}
- if err := insertDefaultModules(ctx, tx, blogID); err != nil {
+ if err := insertDefaultModules(ctx, tx); err != nil {
return err
}
return tx.Commit(ctx)
@@ -129,8 +128,8 @@ func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
// insertDefaultModules is the starting layout: title and menu in the header,
// an RSS link in the footer. Must match what the layout migration gave old blogs.
-func insertDefaultModules(ctx context.Context, tx querier, blogID int64) error {
- _, err := tx.Exec(ctx, `INSERT INTO modules (blog_id, area, kind, sort_order) VALUES
- ($1,'header','title',0), ($1,'header','menu',1), ($1,'footer','rss',0)`, blogID)
+func insertDefaultModules(ctx context.Context, tx querier) error {
+ _, err := tx.Exec(ctx, `INSERT INTO modules (area, kind, sort_order) VALUES
+ ('header','title',0), ('header','menu',1), ('footer','rss',0)`)
return err
}
diff --git a/internal/store/pages.go b/internal/store/pages.go
index cd0e652..55cad33 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -7,7 +7,6 @@ import (
type Page struct {
ID int64
- BlogID int64
Slug string
Title string
IntroMD string
@@ -19,20 +18,20 @@ type Page struct {
PostCount int // filled by ListPages only
}
-const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
+const pageCols = `id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
- err := row.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
+ err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &p, nil
}
-func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
- rows, err := s.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
- FROM pages WHERE blog_id=$1 ORDER BY nav_order, id`, blogID)
+func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
+ FROM pages ORDER BY nav_order, id`)
if err != nil {
return nil, err
}
@@ -40,7 +39,7 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
var out []Page
for rows.Next() {
var p Page
- if err := rows.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
return nil, err
}
out = append(out, p)
@@ -48,34 +47,34 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
return out, rows.Err()
}
-func (s *Store) PageByID(ctx context.Context, blogID, id int64) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) PageByID(ctx context.Context, id int64) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE id=$1`, id))
}
-func (s *Store) PageBySlug(ctx context.Context, blogID int64, slug string) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND slug=$2`, blogID, slug))
+func (bs *BlogStore) PageBySlug(ctx context.Context, slug string) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE slug=$1`, slug))
}
-func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID))
+func (bs *BlogStore) HomePage(ctx context.Context) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages ORDER BY is_home DESC, nav_order, id LIMIT 1`))
}
// CreatePage adds a page at the end of the page list and, when ShowInNav is
// set, to the end of the menu.
-func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
- created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order)
- VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1)) RETURNING `+pageCols,
- p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, nav_order)
+ VALUES ($1,$2,$3,$4,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ p.Slug, p.Title, p.IntroMD, p.IntroHTML))
if err != nil {
return nil, err
}
if p.ShowInNav {
- if err := addMenuPage(ctx, tx, p.BlogID, created.ID); err != nil {
+ if err := addMenuPage(ctx, tx, created.ID); err != nil {
return nil, err
}
created.ShowInNav = true
@@ -84,20 +83,20 @@ func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
}
// UpdatePage saves the page and keeps its menu entry in step with ShowInNav.
-func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6 WHERE blog_id=$1 AND id=$2`,
- p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5 WHERE id=$1`,
+ p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
return wrap(err)
}
if p.ShowInNav {
- err = addMenuPage(ctx, tx, p.BlogID, p.ID)
+ err = addMenuPage(ctx, tx, p.ID)
} else {
- _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, p.BlogID, p.ID)
+ _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, p.ID)
}
if err != nil {
return err
@@ -105,30 +104,30 @@ func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
return tx.Commit(ctx)
}
-func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM pages WHERE blog_id=$1 AND id=$2 AND NOT is_home`, blogID, id)
+func (bs *BlogStore) DeletePage(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, id)
return err
}
// SetHomePage moves the home flag to the given page.
-func (s *Store) SetHomePage(ctx context.Context, blogID, id int64) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) SetHomePage(ctx context.Context, id int64) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE blog_id=$1 AND is_home`, blogID); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE is_home`); err != nil {
return err
}
- if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE blog_id=$1 AND id=$2`, blogID, id); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit(ctx)
}
// MovePage swaps nav_order with the neighbouring page (dir = -1 up, +1 down).
-func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
- pages, err := s.ListPages(ctx, blogID)
+func (bs *BlogStore) MovePage(ctx context.Context, id int64, dir int) error {
+ pages, err := bs.ListPages(ctx)
if err != nil {
return err
}
@@ -143,13 +142,13 @@ func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
return nil
}
pages[idx], pages[j] = pages[j], pages[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, p := range pages { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, p.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$2 WHERE id=$1`, p.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/posts.go b/internal/store/posts.go
index 9f3c4f9..94bc856 100644
--- a/internal/store/posts.go
+++ b/internal/store/posts.go
@@ -31,8 +31,8 @@ func scanPost(row interface{ Scan(...any) error }) (*Post, error) {
return &p, nil
}
-func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -48,21 +48,21 @@ func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post
return out, rows.Err()
}
-// ListPosts returns all posts of a blog for the dashboard, optionally filtered by page.
-func (s *Store) ListPosts(ctx context.Context, blogID int64, pageID int64) ([]Post, error) {
- return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND ($2=0 OR p.page_id=$2) ORDER BY p.created_at DESC, p.id DESC`, blogID, pageID)
+// ListPosts returns all posts of the blog for the dashboard, optionally filtered by page.
+func (bs *BlogStore) ListPosts(ctx context.Context, pageID int64) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE ($1=0 OR p.page_id=$1) ORDER BY p.created_at DESC, p.id DESC`, pageID)
}
// PublishedPosts returns a page of published posts for the public site.
-func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
- posts, err := s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+func (bs *BlogStore) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
+ posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
WHERE p.page_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pageID, limit, offset)
if err != nil {
return nil, 0, err
}
var total int
- err = s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
+ err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
return posts, total, err
}
@@ -74,10 +74,10 @@ type PostRef struct {
CreatedAt time.Time
}
-// PublishedPostIndex lists every published post of a blog, newest first, without bodies.
-func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef, error) {
- rows, err := s.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC`, blogID)
+// PublishedPostIndex lists every published post of the blog, newest first, without bodies.
+func (bs *BlogStore) PublishedPostIndex(ctx context.Context) ([]PostRef, error) {
+ rows, err := bs.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC`)
if err != nil {
return nil, err
}
@@ -93,40 +93,38 @@ func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef
return out, rows.Err()
}
-// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds).
-func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) {
- return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2`, blogID, limit)
+// RecentPublishedPosts returns the newest published posts across the whole blog (for feeds).
+func (bs *BlogStore) RecentPublishedPosts(ctx context.Context, limit int) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $1`, limit)
}
-func (s *Store) PostByID(ctx context.Context, blogID, id int64) (*Post, error) {
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.id=$2`, blogID, id))
+func (bs *BlogStore) PostByID(ctx context.Context, id int64) (*Post, error) {
+ return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id))
}
-func (s *Store) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+func (bs *BlogStore) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
+ return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
WHERE p.page_id=$1 AND p.slug=$2 AND p.published`, pageID, slug))
}
-func (s *Store) CreatePost(ctx context.Context, p *Post) (*Post, error) {
+func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) {
var id int64
- err := s.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
+ err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published).Scan(&id)
if err != nil {
return nil, wrap(err)
}
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id))
+ return bs.PostByID(ctx, id)
}
-// UpdatePost updates a post; the page must belong to the same blog (checked by handler).
-func (s *Store) UpdatePost(ctx context.Context, p *Post) error {
- _, err := s.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`,
+func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error {
+ _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`,
p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published)
return wrap(err)
}
-func (s *Store) DeletePost(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM posts p USING pages g WHERE g.id=p.page_id AND g.blog_id=$1 AND p.id=$2`, blogID, id)
+func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id)
return err
}
diff --git a/internal/store/sections.go b/internal/store/sections.go
index caac251..2f742f7 100644
--- a/internal/store/sections.go
+++ b/internal/store/sections.go
@@ -9,7 +9,6 @@ import (
// Section is an announcement shown on every page and post of a blog.
type Section struct {
ID int64
- BlogID int64
Title string
BodyMD string
BodyHTML string
@@ -25,19 +24,19 @@ type Section struct {
func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c }
func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p }
-const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+const sectionCols = `id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
var s Section
- err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &s, nil
}
-func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -53,44 +52,44 @@ func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]S
return out, rows.Err()
}
-func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) ListSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections ORDER BY sort_order, id`)
}
// EnabledSections is what the public blog renders.
-func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) EnabledSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE enabled ORDER BY sort_order, id`)
}
-func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) SectionByID(ctx context.Context, id int64) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE id=$1`, id))
}
-func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols,
- sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+func (bs *BlogStore) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols,
+ sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
}
-func (s *Store) UpdateSection(ctx context.Context, sec *Section) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+func (bs *BlogStore) UpdateSection(ctx context.Context, sec *Section) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, placement=$5, style=$6, enabled=$7, updated_at=now() WHERE id=$1`,
+ sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
return wrap(err)
}
-func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on)
+func (bs *BlogStore) SetSectionEnabled(ctx context.Context, id int64, on bool) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET enabled=$2, updated_at=now() WHERE id=$1`, id, on)
return err
}
-func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteSection(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM sections WHERE id=$1`, id)
return err
}
// MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down).
-func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error {
- secs, err := s.ListSections(ctx, blogID)
+func (bs *BlogStore) MoveSection(ctx context.Context, id int64, dir int) error {
+ secs, err := bs.ListSections(ctx)
if err != nil {
return err
}
@@ -105,13 +104,13 @@ func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) erro
return nil
}
secs[idx], secs[j] = secs[j], secs[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, sec := range secs { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$2 WHERE id=$1`, sec.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/store.go b/internal/store/store.go
index 472bb2a..c8e97ea 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -1,9 +1,15 @@
// Package store holds the data models and all SQL queries.
+//
+// Store talks to the control database (users, blog registry) and hands out a
+// BlogStore per blog, which is bound to that blog's own database — nothing a
+// BlogStore does can reach another blog's rows.
package store
import (
+ "context"
"errors"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@@ -13,10 +19,36 @@ var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("already exists")
type Store struct {
+ db *pgxpool.Pool // control database
+ cluster *db.Cluster
+}
+
+func New(cluster *db.Cluster) *Store { return &Store{db: cluster.Control(), cluster: cluster} }
+
+// BlogStore runs the content queries of one blog against its database.
+type BlogStore struct {
db *pgxpool.Pool
}
-func New(db *pgxpool.Pool) *Store { return &Store{db: db} }
+// Open returns the blog's store and fills in the settings (title, tagline,
+// theme) kept in the blog database.
+func (s *Store) Open(ctx context.Context, b *Blog) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
+ if err != nil {
+ return nil, err
+ }
+ bs := &BlogStore{db: pool}
+ if err := bs.loadSettings(ctx, b); err != nil {
+ // A database restored underneath a running app (dropdb --force,
+ // createdb, psql < dump) leaves the pool holding dead connections;
+ // drop them and try once more before giving up.
+ pool.Reset()
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ }
+ return bs, nil
+}
// wrap maps driver errors onto the store's sentinel errors.
func wrap(err error) error {
@@ -32,3 +64,8 @@ func wrap(err error) error {
}
return err
}
+
+// querier is what the helpers shared with transactions need: the pool or a pgx.Tx.
+type querier interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+}
diff --git a/internal/store/users.go b/internal/store/users.go
index 3ea9bdb..0c47c24 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -2,6 +2,7 @@ package store
import (
"context"
+ "errors"
"time"
)
@@ -69,9 +70,21 @@ func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) er
return err
}
+// DeleteUser removes the user, their registry row and their blog database.
+// The registry goes first: a stale blog database is harmless and can be
+// dropped by hand, a registry row without a database is not.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
- return err
+ b, err := s.BlogByOwner(ctx, id)
+ if err != nil && !errors.Is(err, ErrNotFound) {
+ return err
+ }
+ if _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id); err != nil {
+ return err
+ }
+ if b != nil {
+ return s.cluster.DropBlogDB(ctx, b.DBName)
+ }
+ return nil
}
// UserWithBlog is a row for the admin overview.
@@ -79,12 +92,11 @@ type UserWithBlog struct {
User
BlogID *int64
Subdomain *string
- BlogTitle *string
}
func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at,
- b.id, b.subdomain, b.title
+ b.id, b.subdomain
FROM users u LEFT JOIN blogs b ON b.owner_id = u.id
ORDER BY u.role, u.username`)
if err != nil {
@@ -95,7 +107,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
for rows.Next() {
var r UserWithBlog
if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt,
- &r.BlogID, &r.Subdomain, &r.BlogTitle); err != nil {
+ &r.BlogID, &r.Subdomain); err != nil {
return nil, err
}
out = append(out, r)