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:
mAi
2026-05-15 13:24:44 +02:00
parent c0466ade36
commit 9f905de461
11 changed files with 1184 additions and 0 deletions

274
store/store.go Normal file
View 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
}