feat: Go HTTP server with tree / detail / new / classify
cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).
store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.
web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
GET / tree of items_unified
GET /i/{path} detail (editable for projax, read-only +
promote form for mai.projects)
POST /i/{path} update projax-native item
POST /i/{path}/promote one-page promote (HTMX-aware fragment for
inline classify)
GET /new?parent={path} create form
POST /new create projax-native item
GET /admin/classify orphan list with inline HTMX promote
GET /healthz DB ping
GET /static/* embedded assets
Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).
Tests (skip when DB env is unset):
TestTreeRenders, TestHealthz,
TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
TestClassifyListsOrphans, TestPromoteRoundTrip.
This commit is contained in:
274
store/store.go
Normal file
274
store/store.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Item is the unified row shape: projax-native items and mai.projects rows
|
||||
// both render through this struct.
|
||||
type Item struct {
|
||||
ID string
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
Path string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Aliases []string
|
||||
Metadata map[string]any
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Source string // "projax" or "mai.projects"
|
||||
SourceRefID *string // mai.projects.id when Source = "mai.projects"
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsArea reports whether this item should be treated as a top-level container.
|
||||
func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") }
|
||||
|
||||
// Editable reports whether the UI may edit this row directly.
|
||||
// mai.projects rows are read-only — they must be promoted first.
|
||||
func (it *Item) Editable() bool { return it.Source == "projax" }
|
||||
|
||||
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
|
||||
func (it *Item) SourceRefDeref() string {
|
||||
if it.SourceRefID == nil {
|
||||
return ""
|
||||
}
|
||||
return *it.SourceRefID
|
||||
}
|
||||
|
||||
// Store wraps a pgx pool with the queries projax needs.
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
var ErrNotFound = errors.New("projax: item not found")
|
||||
|
||||
const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
||||
created_at, updated_at`
|
||||
|
||||
func scanItem(row pgx.Row) (*Item, error) {
|
||||
var it Item
|
||||
if err := row.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func scanItems(rows pgx.Rows) ([]*Item, error) {
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
if err := rows.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll returns every visible row from items_unified. Caller groups by tree.
|
||||
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
order by source = 'projax' desc, path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// GetByPath looks up a single item by path. Projax-native wins on collision.
|
||||
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
|
||||
row := s.Pool.QueryRow(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where path = $1
|
||||
order by source = 'projax' desc
|
||||
limit 1`, path)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// GetByID looks up a single projax-native item by uuid.
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
|
||||
row := s.Pool.QueryRow(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// Areas lists all root-level area items, ordered by slug.
|
||||
func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'projax' and parent_id is null and 'area' = any(kind)
|
||||
order by slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// MaiOrphans lists rows that originated in mai.projects and have not been
|
||||
// promoted yet. Used by /admin/classify.
|
||||
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'mai.projects'
|
||||
order by path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// CreateInput captures the editable surface of a projax-native item.
|
||||
type CreateInput struct {
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
if len(in.Kind) == 0 {
|
||||
return nil, errors.New("kind required")
|
||||
}
|
||||
if in.Title == "" {
|
||||
return nil, errors.New("title required")
|
||||
}
|
||||
if in.Slug == "" {
|
||||
return nil, errors.New("slug required")
|
||||
}
|
||||
if in.Status == "" {
|
||||
in.Status = "active"
|
||||
}
|
||||
var id string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
insert into projax.items
|
||||
(kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
returning id`,
|
||||
in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateInput captures the editable surface of an existing projax-native item.
|
||||
type UpdateInput struct {
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items
|
||||
set title=$2, slug=$3, parent_id=$4, content_md=$5,
|
||||
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10
|
||||
where id=$1 and deleted_at is null`,
|
||||
id, in.Title, in.Slug, in.ParentID, in.ContentMD,
|
||||
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Promote turns a mai.projects orphan into a projax-native project under the
|
||||
// chosen parent. The promotion link causes items_unified to hide the source.
|
||||
func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status)
|
||||
select array['project']::text[], $1, $2, $3,
|
||||
coalesce(goal, ''),
|
||||
coalesce(metadata, '{}'::jsonb),
|
||||
case status
|
||||
when 'sleeping' then 'archived'
|
||||
when 'archived' then 'archived'
|
||||
when 'done' then 'done'
|
||||
else 'active' end
|
||||
from mai.projects where id = $4
|
||||
returning id`,
|
||||
title, slug, parentID, maiID,
|
||||
).Scan(&newID); err != nil {
|
||||
return nil, fmt.Errorf("promote insert: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'mai-project', $2, 'derived-from')`,
|
||||
newID, maiID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("promote link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetByID(ctx, newID)
|
||||
}
|
||||
|
||||
// SoftDelete marks a projax-native item deleted_at = now().
|
||||
func (s *Store) SoftDelete(ctx context.Context, id string) error {
|
||||
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user