feat: add PostgreSQL persistence layer with Neon DB support
This commit is contained in:
195
internal/learnermemory/store_pg.go
Normal file
195
internal/learnermemory/store_pg.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package learnermemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PostgresStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore {
|
||||
return &PostgresStore{pool: pool}
|
||||
}
|
||||
|
||||
func toJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertProfile(profile Profile) (Profile, error) {
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`INSERT INTO learner_profiles (user_id, target_role, stack, interview_timeline, preferences, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
target_role = EXCLUDED.target_role,
|
||||
stack = EXCLUDED.stack,
|
||||
interview_timeline = EXCLUDED.interview_timeline,
|
||||
preferences = EXCLUDED.preferences,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
profile.UserID, profile.TargetRole, toJSON(profile.Stack),
|
||||
profile.InterviewTimeline, toJSON(profile.Preferences), profile.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Profile{}, fmt.Errorf("upsert profile: %w", err)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetProfile(userID string) (Profile, error) {
|
||||
var p Profile
|
||||
var stackJSON, prefsJSON string
|
||||
err := s.pool.QueryRow(context.Background(),
|
||||
`SELECT user_id, target_role, stack, interview_timeline, preferences, updated_at
|
||||
FROM learner_profiles WHERE user_id = $1`, userID,
|
||||
).Scan(&p.UserID, &p.TargetRole, &stackJSON, &p.InterviewTimeline, &prefsJSON, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return Profile{}, ErrProfileNotFound
|
||||
}
|
||||
json.Unmarshal([]byte(stackJSON), &p.Stack)
|
||||
json.Unmarshal([]byte(prefsJSON), &p.Preferences)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertMastery(mastery ConceptMastery) error {
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`INSERT INTO learner_mastery (user_id, concept_id, concept_label, state, evidence, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
||||
ON CONFLICT (user_id, concept_id) DO UPDATE SET
|
||||
concept_label = EXCLUDED.concept_label,
|
||||
state = EXCLUDED.state,
|
||||
evidence = EXCLUDED.evidence,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
mastery.UserID, mastery.Concept.ID, mastery.Concept.Label, string(mastery.State),
|
||||
toJSON(mastery.Evidence), mastery.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert mastery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) AddMisconception(m Misconception) error {
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`INSERT INTO learner_misconceptions (id, user_id, concept, description, evidence, created_at)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6)`,
|
||||
m.ID, m.UserID, toJSON(m.Concept), m.Description,
|
||||
toJSON(m.Evidence), m.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert misconception: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) AddIntervention(i Intervention) error {
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`INSERT INTO learner_interventions (id, user_id, kind, reason, concept, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
|
||||
i.ID, i.UserID, i.Summary, i.Summary, toJSON(i.Concept), i.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert intervention: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) AddReviewSchedule(r ReviewSchedule) error {
|
||||
_, err := s.pool.Exec(context.Background(),
|
||||
`INSERT INTO learner_review_schedules (id, user_id, concept, due_at, created_at)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5)`,
|
||||
r.ID, r.UserID, toJSON(r.Concept), r.UpdatedAt, r.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert review schedule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) Snapshot(userID string) (Snapshot, error) {
|
||||
profile, err := s.GetProfile(userID)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(context.Background(),
|
||||
`SELECT concept_id, concept_label, state, evidence FROM learner_mastery WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("query mastery: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mastery []ConceptMastery
|
||||
for rows.Next() {
|
||||
var m ConceptMastery
|
||||
var evidenceJSON string
|
||||
err := rows.Scan(&m.Concept.ID, &m.Concept.Label, &m.State, &evidenceJSON)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.UserID = userID
|
||||
json.Unmarshal([]byte(evidenceJSON), &m.Evidence)
|
||||
mastery = append(mastery, m)
|
||||
}
|
||||
|
||||
return Snapshot{
|
||||
Profile: profile,
|
||||
Mastery: mastery,
|
||||
Misconceptions: fetchMisconceptions(s.pool, userID),
|
||||
Interventions: fetchInterventions(s.pool, userID),
|
||||
ReviewSchedule: fetchReviewSchedules(s.pool, userID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchMisconceptions(pool *pgxpool.Pool, userID string) []Misconception {
|
||||
rows, _ := pool.Query(context.Background(),
|
||||
`SELECT id, concept, description, evidence, created_at FROM learner_misconceptions WHERE user_id = $1`, userID)
|
||||
defer rows.Close()
|
||||
var items []Misconception
|
||||
for rows.Next() {
|
||||
var m Misconception
|
||||
var conceptJSON, evidenceJSON string
|
||||
rows.Scan(&m.ID, &conceptJSON, &m.Description, &evidenceJSON, &m.UpdatedAt)
|
||||
m.UserID = userID
|
||||
json.Unmarshal([]byte(conceptJSON), &m.Concept)
|
||||
json.Unmarshal([]byte(evidenceJSON), &m.Evidence)
|
||||
items = append(items, m)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func fetchInterventions(pool *pgxpool.Pool, userID string) []Intervention {
|
||||
rows, _ := pool.Query(context.Background(),
|
||||
`SELECT id, kind, reason, concept, created_at FROM learner_interventions WHERE user_id = $1`, userID)
|
||||
defer rows.Close()
|
||||
var items []Intervention
|
||||
for rows.Next() {
|
||||
var i Intervention
|
||||
var conceptJSON string
|
||||
rows.Scan(&i.ID, &i.Summary, &i.Summary, &conceptJSON, &i.UpdatedAt)
|
||||
i.UserID = userID
|
||||
json.Unmarshal([]byte(conceptJSON), &i.Concept)
|
||||
items = append(items, i)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func fetchReviewSchedules(pool *pgxpool.Pool, userID string) []ReviewSchedule {
|
||||
rows, _ := pool.Query(context.Background(),
|
||||
`SELECT id, concept, due_at, created_at FROM learner_review_schedules WHERE user_id = $1`, userID)
|
||||
defer rows.Close()
|
||||
var items []ReviewSchedule
|
||||
for rows.Next() {
|
||||
var r ReviewSchedule
|
||||
var conceptJSON string
|
||||
rows.Scan(&r.ID, &conceptJSON, &r.UpdatedAt, &r.UpdatedAt)
|
||||
r.UserID = userID
|
||||
json.Unmarshal([]byte(conceptJSON), &r.Concept)
|
||||
items = append(items, r)
|
||||
}
|
||||
return items
|
||||
}
|
||||
Reference in New Issue
Block a user