Added code for backend glue

This commit is contained in:
2025-10-13 19:20:24 -04:00
parent 692b069b5b
commit 29a451ab58
25 changed files with 1063 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
package config
import (
"os"
)
type Config struct {
DSN string // e.g. mysql: "user:pass@tcp(127.0.0.1:3306)/mind?parseTime=true"; sqlite: "file:mind.db?_pragma=busy_timeout(5000)"
Driver string // "mysql" or "sqlite"
JWTSecret string
Port string // default 8080
}
func getenv(k, def string) string { v := os.Getenv(k); if v == "" { return def }; return v }
func Load() Config {
return Config{
DSN: getenv("DB_DSN", "file:mind.db?_pragma=busy_timeout(5000)"),
Driver: getenv("DB_DRIVER", "sqlite"),
JWTSecret: getenv("JWT_SECRET", "devsecret"),
Port: getenv("PORT", "8080"),
}
}

47
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,47 @@
package db
import (
"database/sql"
"embed"
"fmt"
"strings"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
"mind/internal/config"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func OpenAndMigrate(cfg config.Config) (*sql.DB, error) {
dsn := cfg.DSN
drv := strings.ToLower(cfg.Driver)
if drv != "mysql" && drv != "sqlite" {
drv = "sqlite"
}
db, err := sql.Open(drv, dsn)
if err != nil { return nil, err }
if err := db.Ping(); err != nil { return nil, err }
if err := runMigrations(db, drv); err != nil { return nil, err }
return db, nil
}
func runMigrations(db *sql.DB, driver string) error {
files, err := migrationFS.ReadDir("migrations")
if err != nil { return err }
for _, f := range files {
b, err := migrationFS.ReadFile("migrations/"+f.Name())
if err != nil { return err }
sqlText := string(b)
if driver == "sqlite" {
// very minor compatibility tweak: drop ENUM
sqlText = strings.ReplaceAll(sqlText, "ENUM('user','assistant')", "TEXT")
}
if _, err := db.Exec(sqlText); err != nil {
return fmt.Errorf("migration %s: %w", f.Name(), err)
}
}
return nil
}

View File

@@ -0,0 +1,40 @@
-- users (minimal for v0)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) UNIQUE,
pass_bcrypt BLOB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- conversations
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
title VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- nodes
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
author_kind ENUM('user','assistant') NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- edges (DAG)
CREATE TABLE IF NOT EXISTS edges (
parent_id INTEGER NOT NULL,
child_id INTEGER NOT NULL,
PRIMARY KEY(parent_id, child_id)
);
-- branches (named pointers)
CREATE TABLE IF NOT EXISTS branches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
name VARCHAR(128) NOT NULL,
head_node_id INTEGER NOT NULL,
UNIQUE (conversation_id, name)
);

View File

@@ -0,0 +1,88 @@
package db
import (
"context"
"database/sql"
"errors"
"mind/internal/models"
)
type Repo struct { db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// Conversations
func (r *Repo) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO conversations(owner_id,title) VALUES(?,?)`, ownerID, title)
if err != nil { return 0, err }
return res.LastInsertId()
}
func (r *Repo) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, owner_id, title, created_at FROM conversations WHERE owner_id=? ORDER BY id DESC`, ownerID)
if err != nil { return nil, err }
defer rows.Close()
var out []models.Conversation
for rows.Next() {
var c models.Conversation
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Title, &c.Created); err != nil { return nil, err }
out = append(out, c)
}
return out, nil
}
// Nodes & Edges
func (r *Repo) CreateNode(ctx context.Context, convID int64, authorKind, content string) (int64, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO nodes(conversation_id,author_kind,content) VALUES(?,?,?)`, convID, authorKind, content)
if err != nil { return 0, err }
return res.LastInsertId()
}
func (r *Repo) Link(ctx context.Context, parentID, childID int64) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO edges(parent_id,child_id) VALUES(?,?)`, parentID, childID)
return err
}
func (r *Repo) GetNode(ctx context.Context, id int64) (models.Node, error) {
var n models.Node
err := r.db.QueryRowContext(ctx, `SELECT id,conversation_id,author_kind,content,created_at FROM nodes WHERE id=?`, id).
Scan(&n.ID, &n.ConversationID, &n.AuthorKind, &n.Content, &n.Created)
return n, err
}
// Branches
func (r *Repo) CreateOrGetBranch(ctx context.Context, convID int64, name string, headNodeID int64) (models.Branch, error) {
// Try insert
_, _ = r.db.ExecContext(ctx, `INSERT INTO branches(conversation_id,name,head_node_id) VALUES(?,?,?)`, convID, name, headNodeID)
return r.GetBranch(ctx, convID, name)
}
func (r *Repo) GetBranch(ctx context.Context, convID int64, name string) (models.Branch, error) {
var b models.Branch
err := r.db.QueryRowContext(ctx, `SELECT id,conversation_id,name,head_node_id FROM branches WHERE conversation_id=? AND name=?`, convID, name).
Scan(&b.ID, &b.ConversationID, &b.Name, &b.HeadNodeID)
return b, err
}
func (r *Repo) MoveBranchHead(ctx context.Context, branchID, newHeadID int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE branches SET head_node_id=? WHERE id=?`, newHeadID, branchID)
return err
}
// Latest-parent walk for linearize
func (r *Repo) LatestParent(ctx context.Context, childID int64) (models.Node, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT n.id,n.conversation_id,n.author_kind,n.content,n.created_at
FROM edges e
JOIN nodes n ON n.id=e.parent_id
WHERE e.child_id=?
ORDER BY n.created_at DESC
LIMIT 1`, childID)
var n models.Node
if err := row.Scan(&n.ID, &n.ConversationID, &n.AuthorKind, &n.Content, &n.Created); err != nil {
if errors.Is(err, sql.ErrNoRows) { return models.Node{}, false, nil }
return models.Node{}, false, err
}
return n, true, nil
}

View File

@@ -0,0 +1,12 @@
package glue
import (
"context"
"mind/internal/models"
)
type ForkReq struct { ConversationID int64; Name string; HeadNodeID int64 }
func (g *Glue) ForkBranch(ctx context.Context, fr ForkReq) (models.Branch, error) {
return g.repo.CreateOrGetBranch(ctx, fr.ConversationID, fr.Name, fr.HeadNodeID)
}

View File

@@ -0,0 +1,39 @@
package glue
import (
"context"
)
type CompletionReq struct {
ConversationID int64 `json:"conversation_id"`
BranchName string `json:"branch"`
Prompt string `json:"prompt"`
}
type CompletionResp struct {
PromptNodeID int64 `json:"prompt_node_id"`
AnswerNodeID int64 `json:"answer_node_id"`
Answer string `json:"answer"`
}
// For v0 we stub the answer as a simple echo with a prefix.
func (g *Glue) AppendCompletion(ctx context.Context, req CompletionReq) (CompletionResp, error) {
b, err := g.repo.GetBranch(ctx, req.ConversationID, req.BranchName)
if err != nil { return CompletionResp{}, err }
// 1) create user prompt node
promptID, err := g.repo.CreateNode(ctx, req.ConversationID, "user", req.Prompt)
if err != nil { return CompletionResp{}, err }
if err := g.repo.Link(ctx, b.HeadNodeID, promptID); err != nil { return CompletionResp{}, err }
// 2) create assistant answer node (stub)
answerText := "(stub) You said: " + req.Prompt
answerID, err := g.repo.CreateNode(ctx, req.ConversationID, "assistant", answerText)
if err != nil { return CompletionResp{}, err }
if err := g.repo.Link(ctx, promptID, answerID); err != nil { return CompletionResp{}, err }
// 3) move branch head
if err := g.repo.MoveBranchHead(ctx, b.ID, answerID); err != nil { return CompletionResp{}, err }
return CompletionResp{PromptNodeID: promptID, AnswerNodeID: answerID, Answer: answerText}, nil
}

View File

@@ -0,0 +1,19 @@
package glue
import (
"context"
"mind/internal/db"
"mind/internal/models"
)
type Glue struct { repo *db.Repo }
func NewGlue(r *db.Repo) *Glue { return &Glue{repo: r} }
func (g *Glue) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
return g.repo.CreateConversation(ctx, ownerID, title)
}
func (g *Glue) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
return g.repo.ListConversations(ctx, ownerID)
}

View File

@@ -0,0 +1,41 @@
package glue
import (
"context"
"mind/internal/models"
)
type Linearized struct {
Nodes []models.Node `json:"nodes"`
Text string `json:"text"`
}
func (g *Glue) LinearizeByBranch(ctx context.Context, convID int64, branchName string) (Linearized, error) {
b, err := g.repo.GetBranch(ctx, convID, branchName)
if err != nil { return Linearized{}, err }
return g.linearizeFromHead(ctx, b.HeadNodeID)
}
func (g *Glue) linearizeFromHead(ctx context.Context, head int64) (Linearized, error) {
var seq []models.Node
// Walk parents by latest-created parent until none
curID := head
for {
n, err := g.repo.GetNode(ctx, curID)
if err != nil { return Linearized{}, err }
seq = append(seq, n)
p, ok, err := g.repo.LatestParent(ctx, curID)
if err != nil { return Linearized{}, err }
if !ok { break }
curID = p.ID
}
// Reverse seq to root→head, and stitch text
for i, j := 0, len(seq)-1; i < j; i, j = i+1, j-1 { seq[i], seq[j] = seq[j], seq[i] }
var txt string
for _, n := range seq {
role := "[user]"
if n.AuthorKind == "assistant" { role = "[assistant]" }
txt += role + "\n" + n.Content + "\n\n"
}
return Linearized{Nodes: seq, Text: txt}, nil
}

View File

@@ -0,0 +1,60 @@
package http
import (
"encoding/json"
"net/http"
"strconv"
"mind/internal/glue"
)
// POST /conversations {"title":"demo","owner_id":1}
// GET /conversations?owner_id=1
func (s *server) conversations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var in struct { Title string `json:"title"`; OwnerID int64 `json:"owner_id"` }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
id, err := s.glue.CreateConversation(r.Context(), in.OwnerID, in.Title)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, map[string]any{"id": id})
case http.MethodGet:
ownerStr := r.URL.Query().Get("owner_id")
owner, _ := strconv.ParseInt(ownerStr, 10, 64)
out, err := s.glue.ListConversations(r.Context(), owner)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, out)
default:
w.WriteHeader(405)
}
}
// POST /branches {conversation_id, name, head_node_id}
func (s *server) branches(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { w.WriteHeader(405); return }
var in glue.ForkReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
b, err := s.glue.ForkBranch(r.Context(), in)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, b)
}
// POST /completion {conversation_id, branch, prompt}
func (s *server) completion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { w.WriteHeader(405); return }
var in glue.CompletionReq
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
resp, err := s.glue.AppendCompletion(r.Context(), in)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, resp)
}
// GET /linearize?conversation_id=..&branch=main
func (s *server) linearize(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { w.WriteHeader(405); return }
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
branch := r.URL.Query().Get("branch")
out, err := s.glue.LinearizeByBranch(r.Context(), convID, branch)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, out)
}

View File

@@ -0,0 +1,7 @@
package http
import "net/http"
func withCommon(next http.Handler) http.Handler {
return http.TimeoutHandler(next, 60_000_000_000, "timeout") // 60s
}

View File

@@ -0,0 +1,39 @@
package http
import (
"encoding/json"
"net/http"
"mind/internal/config"
"mind/internal/glue"
"mind/internal/logx"
)
type server struct {
log *logx.Logger
cfg config.Config
glue *glue.Glue
}
func NewRouter(l *logx.Logger, c config.Config, g *glue.Glue) http.Handler {
s := &server{log: l, cfg: c, glue: g}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/conversations", s.conversations)
mux.HandleFunc("/branches", s.branches)
mux.HandleFunc("/completion", s.completion)
mux.HandleFunc("/linearize", s.linearize)
return withCommon(mux)
}
func (s *server) health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte("ok"))
}
// Helpers
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,19 @@
package logx
import (
"log"
)
type Logger struct{}
func New() *Logger {
return &Logger{}
}
func (l *Logger) Infof(format string, v ...any) {
log.Printf("INFO "+format, v...)
}
func (l *Logger) Errorf(format string, v ...any) {
log.Printf("ERROR "+format, v...)
}

View File

@@ -0,0 +1,30 @@
package models
import "time"
type User struct {
ID int64
Email string
}
type Conversation struct {
ID int64
OwnerID int64
Title string
Created time.Time
}
type Node struct {
ID int64
ConversationID int64
AuthorKind string // 'user' | 'assistant'
Content string
Created time.Time
}
type Branch struct {
ID int64
ConversationID int64
Name string
HeadNodeID int64
}