migrate to SQLite and prepare for MCP deployment
Switch from PostgreSQL to SQLite (modernc.org/sqlite, pure Go) for simpler deployment on the MCP platform. Fix URL normalization to preserve query parameters so sites like YouTube deduplicate correctly. Add Dockerfile, Makefile, and MCP service definition. Add pg2sqlite migration tool. Support $PORT env var for MCP port assignment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
links/db.go
54
links/db.go
@@ -2,48 +2,38 @@ package links
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/config"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
// The keys are all set up as constants to avoid typos and
|
||||
// runtime surprises. I've prefixed the names with k, which
|
||||
// perhaps counterintuitively is not for 'kyle' but for 'key'.
|
||||
kDriver = "DB_ENGINE"
|
||||
kName = "DB_NAME"
|
||||
kUser = "DB_USER"
|
||||
kPass = "DB_PASSWORD"
|
||||
kHost = "DB_HOST"
|
||||
kPort = "DB_PORT"
|
||||
|
||||
defaultDriver = "postgres"
|
||||
defaultName = "kls"
|
||||
defaultUser = "kls"
|
||||
defaultPort = "5432"
|
||||
kDBPath = "DB_PATH"
|
||||
defaultPath = "kls.db"
|
||||
)
|
||||
|
||||
func connString(user, pass, host, name, port string) string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=verify-full",
|
||||
user, pass, host, port, name)
|
||||
}
|
||||
const createTable = `CREATE TABLE IF NOT EXISTS urls (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
nurl TEXT NOT NULL,
|
||||
short TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
)`
|
||||
|
||||
// Connect will try to open a connection to the database using the
|
||||
// standard configuration vars, etc.
|
||||
func Connect(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
driver := config.GetDefault(kDriver, defaultDriver)
|
||||
if driver != defaultDriver {
|
||||
return nil, fmt.Errorf("database: unsupported driver %s", driver)
|
||||
// Connect opens the SQLite database and ensures the schema exists.
|
||||
func Connect(ctx context.Context) (*sql.DB, error) {
|
||||
path := config.GetDefault(kDBPath, defaultPath)
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database: %w", err)
|
||||
}
|
||||
|
||||
user := config.GetDefault(kUser, defaultUser)
|
||||
pass := config.Get(kPass)
|
||||
host := config.Get(kHost)
|
||||
name := config.GetDefault(kName, defaultName)
|
||||
port := config.GetDefault(kPort, defaultPort)
|
||||
cstr := connString(user, pass, host, name, port)
|
||||
if _, err := db.ExecContext(ctx, createTable); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("database: schema init: %w", err)
|
||||
}
|
||||
|
||||
return pgxpool.Connect(ctx, cstr)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
73
links/url.go
73
links/url.go
@@ -2,17 +2,16 @@ package links
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
)
|
||||
|
||||
var psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
|
||||
var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Question)
|
||||
|
||||
type URL struct {
|
||||
ID string
|
||||
@@ -26,26 +25,27 @@ func (u *URL) Timestamp() string {
|
||||
return u.CreatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func (u *URL) Store(ctx context.Context, db *pgxpool.Pool) error {
|
||||
stmt := psql.Insert("urls").
|
||||
func (u *URL) Store(ctx context.Context, db *sql.DB) error {
|
||||
stmt := sq.Insert("urls").
|
||||
Columns("id", "url", "nurl", "short", "created_at").
|
||||
Values(u.ID, u.URL, u.NURL, u.Short, u.CreatedAt)
|
||||
Values(u.ID, u.URL, u.NURL, u.Short, u.CreatedAt.Format(time.RFC3339))
|
||||
query, args, err := stmt.ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, query, args...)
|
||||
_, err = db.ExecContext(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Normalize cleans the URL to only the parts we care about.
|
||||
func Normalize(u *url.URL) *url.URL {
|
||||
norm := &url.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawPath: u.RawPath,
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawPath: u.RawPath,
|
||||
RawQuery: u.RawQuery,
|
||||
}
|
||||
return norm
|
||||
}
|
||||
@@ -61,18 +61,19 @@ func NormalizeString(s string) (string, error) {
|
||||
}
|
||||
|
||||
// Clean should scrub out junk from the URL.
|
||||
func Clean(u *url.URL, keepQuery bool) *url.URL {
|
||||
func Clean(u *url.URL, stripQuery bool) *url.URL {
|
||||
norm := &url.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawPath: u.RawPath,
|
||||
RawQuery: u.RawQuery,
|
||||
Fragment: u.Fragment,
|
||||
RawFragment: u.RawFragment,
|
||||
}
|
||||
|
||||
if keepQuery {
|
||||
norm.RawQuery = u.RawQuery
|
||||
if stripQuery {
|
||||
norm.RawQuery = ""
|
||||
}
|
||||
return norm
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func FromString(s string) (*URL, error) {
|
||||
return New(u), nil
|
||||
}
|
||||
|
||||
func Lookup(ctx context.Context, db *pgxpool.Pool, s string) (string, error) {
|
||||
func Lookup(ctx context.Context, db *sql.DB, s string) (string, error) {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
@@ -115,14 +116,14 @@ func Lookup(ctx context.Context, db *pgxpool.Pool, s string) (string, error) {
|
||||
|
||||
u = Normalize(u)
|
||||
|
||||
stmt := psql.Select("short").From("urls").
|
||||
stmt := sq.Select("short").From("urls").
|
||||
Where(squirrel.Eq{"nurl": u.String()})
|
||||
query, args, err := stmt.ToSql()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
row := db.QueryRow(ctx, query, args...)
|
||||
row := db.QueryRowContext(ctx, query, args...)
|
||||
|
||||
var short string
|
||||
err = row.Scan(&short)
|
||||
@@ -133,13 +134,13 @@ func Lookup(ctx context.Context, db *pgxpool.Pool, s string) (string, error) {
|
||||
return short, nil
|
||||
}
|
||||
|
||||
func StoreURL(ctx context.Context, db *pgxpool.Pool, s string) (string, error) {
|
||||
func StoreURL(ctx context.Context, db *sql.DB, s string) (string, error) {
|
||||
short, err := Lookup(ctx, db, s)
|
||||
if err == nil {
|
||||
return short, nil
|
||||
}
|
||||
|
||||
if err != pgx.ErrNoRows {
|
||||
if err != sql.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -156,48 +157,50 @@ func StoreURL(ctx context.Context, db *pgxpool.Pool, s string) (string, error) {
|
||||
return u.Short, nil
|
||||
}
|
||||
|
||||
func RetrieveURL(ctx context.Context, db *pgxpool.Pool, short string) (string, error) {
|
||||
func RetrieveURL(ctx context.Context, db *sql.DB, short string) (string, error) {
|
||||
log.Printf("look up url for short code %s", short)
|
||||
stmt := psql.Select("url").From("urls").
|
||||
stmt := sq.Select("url").From("urls").
|
||||
Where(squirrel.Eq{"short": short})
|
||||
query, args, err := stmt.ToSql()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
row := db.QueryRow(ctx, query, args...)
|
||||
var url string
|
||||
row := db.QueryRowContext(ctx, query, args...)
|
||||
var u string
|
||||
|
||||
err = row.Scan(&url)
|
||||
err = row.Scan(&u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return url, nil
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func FetchAll(ctx context.Context, db *pgxpool.Pool) ([]*URL, error) {
|
||||
stmt := psql.Select("*").From("urls").OrderBy("created_at")
|
||||
func FetchAll(ctx context.Context, db *sql.DB) ([]*URL, error) {
|
||||
stmt := sq.Select("id", "url", "nurl", "short", "created_at").
|
||||
From("urls").OrderBy("created_at")
|
||||
query, args, err := stmt.ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(ctx, query, args...)
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var urls []*URL
|
||||
for rows.Next() {
|
||||
u := &URL{}
|
||||
err = rows.Scan(
|
||||
&u.ID,
|
||||
&u.URL,
|
||||
&u.NURL,
|
||||
&u.Short,
|
||||
&u.CreatedAt,
|
||||
)
|
||||
var ts string
|
||||
err = rows.Scan(&u.ID, &u.URL, &u.NURL, &u.Short, &ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.CreatedAt, err = time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user