storage layer

This commit is contained in:
Abdulmujeeb Raji 2024-12-30 10:02:01 +00:00
parent 27170540d0
commit d6c9511d66
Signed by: midnadimple
GPG key ID: EB02C582F8C3962B
22 changed files with 328 additions and 24 deletions

View file

@ -1,24 +1,19 @@
# Discourse the right way
**Fog** is a **F**orum mixed with a Bl**og**, intended to encourage fruitful
discussion and the spread of knowledge.
discussion and the spread of knowledge.
## Posts
These are your typical blog posts, written in Markdown. They can include video,
images and (one day) custom JS for interactivity.
To get started:
1. Install [Go](https://go.dev).
2. Install [migrate](https://github.com/golang-migrate/migrate):
```sh
$ go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
```
3. Install [PostgreSQL](https://postgresql.org).
4. Create a new Postgres user and database.
5. Set your environment variables. See [`docs/envvars.mds`](docs/envvars.md)
for an list of variables that need to be set.
6. Run `scripts/migrateup` to setup the database tables.
7. Run `go run cmd/api` to start the backend.
Posts can be replies to other posts and will be directly linked underneath the
main post. You can reply to multiple posts at once, and you will be linked in
the replies of all posts. Neighbouring posts which reply to the same parent post
will be linked under the reply post.
After you make a post, you must wait a customizable amount of time before
making another (default is a day). Think before you speak.
There is no voting system and no comments system. If you have something to say
about a post, say it. Views on a post are also not recorded.
## Channels
Channels are a way of sorting posts by their general theme. All posts made to a
channel can be sorted by newest (default), oldest and random.
Moderators can pin posts to the top of channels.
Frontend has a seperate setup. See [`web/README.md`](web/README.md) for more
info.

View file

@ -8,15 +8,27 @@ import (
"path"
"strings"
"time"
"midnadimple.com/fog/internal/store"
)
type application struct {
config config
v1 *v1Handler
store store.Storage
v1 *v1Handler
}
type config struct {
addr string
db dbConfig
}
type dbConfig struct {
addr string
maxOpenConns int
maxIdleConns int
maxIdleTime time.Duration
}
func (h *application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -71,6 +83,10 @@ func (h *application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (app *application) run() error {
app.v1 = &v1Handler{
healthCheck: &healthCheckHandler{},
}
srv := &http.Server{
Addr: app.config.addr,
Handler: app,

View file

@ -2,22 +2,39 @@ package main
import (
"log"
"time"
"midnadimple.com/fog/internal/db"
"midnadimple.com/fog/internal/env"
"midnadimple.com/fog/internal/store/postgres"
)
func main() {
cfg := config{
addr: env.GetString("FOG_ADDR", ":8080"),
db: dbConfig{
addr: env.GetString("FOG_DB_ADDR", "postgres://foguser:fogpass@localhost/fog?sslmode=disable"),
maxOpenConns: env.GetInt("FOG_DB_MAX_OPEN_CONNS", 30),
maxIdleConns: env.GetInt("FOG_DB_MAX_IDLE_CONNS", 30),
maxIdleTime: env.GetDuration("FOG_DB_MAX_IDLE_TIME", 15*time.Minute),
},
}
v1 := &v1Handler{
healthCheck: &healthCheckHandler{},
db, err := db.New(
cfg.db.addr,
cfg.db.maxOpenConns,
cfg.db.maxIdleConns,
cfg.db.maxIdleTime,
)
if err != nil {
log.Fatalln(err)
}
store := postgres.NewPostgresStorage(db)
app := &application{
config: cfg,
v1: v1,
store: store,
}
log.Fatal(app.run())

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,9 @@
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE IF NOT EXISTS users(
id bigserial PRIMARY KEY,
username varchar(255) UNIQUE NOT NULL,
email citext UNIQUE NOT NULL,
password bytea NOT NULL,
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW()
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS posts

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS posts(
id bigserial PRIMARY KEY,
content text NOT NULL,
title text NOT NULL,
user_id bigserial NOT NULL,
tags text[],
reply_ids integer[],
created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW()
);

View file

@ -0,0 +1,3 @@
ALTER TABLE
posts
DROP CONSTRAINT fk_user;

View file

@ -0,0 +1,3 @@
ALTER TABLE
posts
ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id);

22
docs/design.md Normal file
View file

@ -0,0 +1,22 @@
# Posts
These are your typical blog posts, written in Markdown. They can include video,
images and (one day) custom JS for interactivity.
Posts can be replies to other posts and will be directly linked underneath the
main post. You can reply to multiple posts at once, and you will be linked in
the replies of all posts. Neighbouring posts which reply to the same parent post
will be linked under the reply post.
After you make a post, you must wait a customizable amount of time before
making another (default is a day). Think before you speak.
There is no voting system and no comments system. If you have something to say
about a post, say it. Views on a post are also not recorded.
# Channels
Channels are a way of sorting posts by their general theme. All posts in a given
channel can be sorted by newest (default), oldest and random.
Moderators can pin posts to the top of channels.
A post must belong to one or more channels.

20
docs/envvars.md Normal file
View file

@ -0,0 +1,20 @@
# List of all Environment Variables
You should set these in your `.profile`. Even though there are defaults, the
scripts still expect to have these variables in order to perform migrations,
so you should set them.
## FOG_ADDR
Address the backend server listens on. Default is ":8080"
## FOG_DB_ADDR
Address of the Postgres database. Default is:
"postgres://foguser:fogpass@localhost/fog?sslmode=disable"
## FOG_DB_MAX_OPEN_CONNS
How many database connections can be open at once. Default is 30
## FOG_DB_MAX_IDLE_CONNS
How many database connections can be idling at once. Default is 30
## FOG_DB_MAX_IDLE_TIME
How long a database connection should idle before being closed Default is "15m"

2
go.mod
View file

@ -1,3 +1,5 @@
module midnadimple.com/fog
go 1.23.4
require github.com/lib/pq v1.10.9

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=

31
internal/db/db.go Normal file
View file

@ -0,0 +1,31 @@
package db
import (
"context"
"database/sql"
"log"
"time"
)
func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) {
db, err := sql.Open("postgres", addr)
if err != nil {
return nil, err
}
defer db.Close()
log.Println("database connection pool established")
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxIdleTime(maxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}

19
internal/env/env.go vendored
View file

@ -4,11 +4,13 @@ import (
"log"
"os"
"strconv"
"time"
)
func GetString(key, fallback string) string {
val, ok := os.LookupEnv(key)
if !ok {
log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%s'", key, fallback)
return fallback
}
@ -18,6 +20,7 @@ func GetString(key, fallback string) string {
func GetInt(key string, fallback int) int {
val, ok := os.LookupEnv(key)
if !ok {
log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%d'", key, fallback)
return fallback
}
@ -29,3 +32,19 @@ func GetInt(key string, fallback int) int {
return valInt
}
func GetDuration(key string, fallback time.Duration) time.Duration {
val, ok := os.LookupEnv(key)
if !ok {
log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%s'", key, fallback)
return fallback
}
duration, err := time.ParseDuration(val)
if err != nil {
log.Printf("Failed to convert environment variable with key %s and value %s to duration. Using fallback %d. (err: %s)", key, val, fallback, err)
return fallback
}
return duration
}

View file

@ -0,0 +1,20 @@
package postgres
import (
"database/sql"
"time"
"midnadimple.com/fog/internal/store"
)
func NewPostgresStorage(db *sql.DB) store.Storage {
return store.Storage{
Posts: &PostsStore{db},
Users: &UsersStore{db},
}
}
func parseTimestamp(valPtr *time.Time, str string) error {
return nil
}

View file

@ -0,0 +1,52 @@
package postgres
import (
"context"
"database/sql"
"time"
"github.com/lib/pq"
"midnadimple.com/fog/internal/store"
)
type PostsStore struct {
DB *sql.DB
}
func (s *PostsStore) Create(ctx context.Context, post *store.Post) error {
query := `
INSERT INTO posts (content, title, user_id, tags, reply_ids)
VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at
`
var createdAtStr, updatedAtStr string
err := s.DB.QueryRowContext(
ctx,
query,
post.Content,
post.Title,
post.UserID,
pq.Array(post.Tags),
pq.Array(post.ReplyIDs),
).Scan(
&post.ID,
&createdAtStr,
&updatedAtStr,
)
if err != nil {
return err
}
post.CreatedAt, err = pq.ParseTimestamp(time.UTC, createdAtStr)
if err != nil {
return err
}
post.UpdatedAt, err = pq.ParseTimestamp(time.UTC, updatedAtStr)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,44 @@
package postgres
import (
"context"
"database/sql"
"time"
"github.com/lib/pq"
"midnadimple.com/fog/internal/store"
)
type UsersStore struct {
DB *sql.DB
}
func (s *UsersStore) Create(ctx context.Context, user *store.User) error {
query := `
INSERT INTO users (username, password, email) VALUES($1, $2, $3) RETURNING id, created_at
`
var createdAtStr string
err := s.DB.QueryRowContext(
ctx,
query,
user.Username,
user.Password,
user.Email,
).Scan(
&user.ID,
&createdAtStr,
)
if err != nil {
return err
}
user.CreatedAt, err = pq.ParseTimestamp(time.UTC, createdAtStr)
if err != nil {
return err
}
return nil
}

34
internal/store/storage.go Normal file
View file

@ -0,0 +1,34 @@
package store
import (
"context"
"time"
)
type Post struct {
ID int64 `json:"id"`
Content string `json:"content"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
Tags []int64 `json:"tags"`
ReplyIDs []int64 `json:"reply_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email int64 `json:"email"`
Password string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Storage struct {
Posts interface {
Create(context.Context, *Post) error
}
Users interface {
Create(context.Context, *User) error
}
}

1
scripts/migratedown.ps1 Normal file
View file

@ -0,0 +1 @@
migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" down

1
scripts/migratenew.ps1 Normal file
View file

@ -0,0 +1 @@
migrate create -seq -ext sql -dir "./cmd/migrate/migrations" $args[0]

1
scripts/migrateup.ps1 Normal file
View file

@ -0,0 +1 @@
migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" up