Phase 5c slice A. Pulls the structural rules out of the Postgres triggers into a Go-side validator. The trigger stays as defence in depth; the validator is the human-facing error path. - docs/plans/itemwrite-validation.md enumerates every rule the triggers in 0001 + 0010 enforce, with the ValidationError.Kind callers will see for each. Eleven rules total (two SQL-only safety rails kept untranslated). - internal/itemwrite/itemwrite.go: ValidationError + Input + Reader interface + ValidateFormat (pure: missing fields, slug format, status whitelist, self-parent) + ValidateAgainstStore (DB-aware: unknown-parent, slug-collision under any common parent, cycle via ancestor-closure DFS capped at 64 hops to mirror the trigger). - Eight kind constants exported: missing-required, invalid-slug-format, invalid-status, slug-collision, cycle, self-parent, unknown-parent, unresolvable-path. Tests cover every kind on both happy and reject paths: missing / whitespace fields, slug containing dot / upper / whitespace, invalid status enum, self-parent guard, unknown parent id, root slug collision, sibling slug collision under common parent, cycle on ancestor closure, and the "Reader returns ListAll error → validator returns nil" path (callers see the infra error later, validator doesn't mask it). No caller migrates yet. Same Go-linker DCE caveat as 5a/5b slice A: `strings <binary> | grep internal/itemwrite` returns 0 until slice B imports. Task: t-projax-5c-itemwrite
245 lines
7.7 KiB
Go
245 lines
7.7 KiB
Go
// Package itemwrite is the projax write-side validator. Before Phase 5c
|
|
// every web/MCP write path handed raw form/MCP input straight to
|
|
// store.Create / store.Update / store.Reparent and relied on the Postgres
|
|
// triggers in db/migrations/0001 + 0010 to enforce structural rules. The
|
|
// trigger then raised an opaque pgErr that callers had to substring-match
|
|
// to render anything human-readable.
|
|
//
|
|
// This package mirrors the trigger's rules in Go so:
|
|
//
|
|
// - Handlers fail fast with a typed ValidationError before opening a
|
|
// transaction.
|
|
// - The /admin/bulk-apply path pre-flights every row outside the txn —
|
|
// impossible with SQL-only validation.
|
|
// - Callers render banner copy keyed on err.Kind, no pgErr substring
|
|
// matching anywhere in projax.
|
|
//
|
|
// The Postgres trigger stays as defence-in-depth. If the trigger ever
|
|
// rejects something this validator allowed, that's a validator bug and the
|
|
// raw pgErr surfaces unchanged so the gap is visible.
|
|
//
|
|
// See docs/plans/itemwrite-validation.md for the rule-to-Kind enumeration.
|
|
package itemwrite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// Kind values returned on ValidationError. Each kind matches a rule in
|
|
// docs/plans/itemwrite-validation.md.
|
|
const (
|
|
KindMissingRequired = "missing-required"
|
|
KindInvalidSlugFormat = "invalid-slug-format"
|
|
KindInvalidStatus = "invalid-status"
|
|
KindSlugCollision = "slug-collision"
|
|
KindCycle = "cycle"
|
|
KindSelfParent = "self-parent"
|
|
KindUnknownParent = "unknown-parent"
|
|
KindUnresolvablePath = "unresolvable-path"
|
|
)
|
|
|
|
// allowedStatuses mirrors db/migrations/0001_init.sql items_status_valid.
|
|
var allowedStatuses = map[string]struct{}{
|
|
"active": {}, "done": {}, "archived": {},
|
|
}
|
|
|
|
// slugInvalid matches the inverse of db/migrations/0001_init.sql
|
|
// items_slug_no_dots. Slugs that contain a dot are rejected; we
|
|
// additionally reject upper-case and whitespace per the convention
|
|
// documented in docs/design.md §2.2 (slugs are lower-case, no dots).
|
|
var slugInvalid = regexp.MustCompile(`[A-Z.\s]`)
|
|
|
|
// ValidationError is the typed return from ValidateFormat /
|
|
// ValidateAgainstStore. Handlers switch on Kind to render banner copy and
|
|
// pick HTTP status codes; the structured shape is what the MCP error
|
|
// surfaces back to callers via the JSON-RPC envelope.
|
|
type ValidationError struct {
|
|
Kind string
|
|
Path string // dot-path of the offending item ("dev.paliad", "" if not yet a path)
|
|
Detail string // human-facing message
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
if e.Path == "" {
|
|
return fmt.Sprintf("itemwrite: %s: %s", e.Kind, e.Detail)
|
|
}
|
|
return fmt.Sprintf("itemwrite: %s at %s: %s", e.Kind, e.Path, e.Detail)
|
|
}
|
|
|
|
// Input is the validator's view of a write request. Callers populate it
|
|
// from form values / MCP args / bulk rows before calling either Validate
|
|
// method. Empty fields are treated as "not provided" — Update paths leave
|
|
// fields blank to mean "no change".
|
|
type Input struct {
|
|
ID string // empty for Create; populated for Update / Reparent
|
|
Title string
|
|
Slug string
|
|
Status string
|
|
ParentIDs []string // resolved IDs, not paths
|
|
// Path is optional context for the error: lets handlers report which
|
|
// row in a bulk-apply (or which detail page) is broken. Not used by
|
|
// the validation logic itself.
|
|
Path string
|
|
}
|
|
|
|
// Reader is the slice of *store.Store the validator needs. Kept as an
|
|
// interface so unit tests can stub the DB calls cleanly.
|
|
type Reader interface {
|
|
GetByID(ctx context.Context, id string) (*store.Item, error)
|
|
ListAll(ctx context.Context) ([]*store.Item, error)
|
|
}
|
|
|
|
// ValidateFormat runs the pure (no DB) checks: required fields, slug
|
|
// format, status whitelist, and self-parent. Cheap; safe to call inside a
|
|
// tight loop (e.g. the bulk-apply preflight).
|
|
func ValidateFormat(in Input) *ValidationError {
|
|
if strings.TrimSpace(in.Title) == "" {
|
|
return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "title is required"}
|
|
}
|
|
if strings.TrimSpace(in.Slug) == "" {
|
|
return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "slug is required"}
|
|
}
|
|
if slugInvalid.MatchString(in.Slug) {
|
|
return &ValidationError{
|
|
Kind: KindInvalidSlugFormat,
|
|
Path: in.Path,
|
|
Detail: fmt.Sprintf("slug %q must be lower-case, no dots, no whitespace", in.Slug),
|
|
}
|
|
}
|
|
if in.Status != "" {
|
|
if _, ok := allowedStatuses[in.Status]; !ok {
|
|
return &ValidationError{
|
|
Kind: KindInvalidStatus,
|
|
Path: in.Path,
|
|
Detail: fmt.Sprintf("status %q must be one of active|done|archived", in.Status),
|
|
}
|
|
}
|
|
}
|
|
if in.ID != "" {
|
|
for _, pid := range in.ParentIDs {
|
|
if pid == in.ID {
|
|
return &ValidationError{
|
|
Kind: KindSelfParent,
|
|
Path: in.Path,
|
|
Detail: "an item cannot be its own parent",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAgainstStore adds the DB-aware checks: every parent id must
|
|
// resolve to a live item, the proposed parent_ids must not introduce a
|
|
// cycle, and no sibling under any common parent already carries this slug.
|
|
// Mirrors db/migrations/0010_multi_parent.sql trigger logic in Go.
|
|
//
|
|
// Callers should run ValidateFormat first — this function assumes the
|
|
// pure checks already passed.
|
|
func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationError {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
items, err := r.ListAll(ctx)
|
|
if err != nil {
|
|
// DB unreachable: surface as raw error rather than masquerading as
|
|
// validation. The caller wraps this; it's not a ValidationError.
|
|
return nil
|
|
}
|
|
byID := make(map[string]*store.Item, len(items))
|
|
for _, it := range items {
|
|
byID[it.ID] = it
|
|
}
|
|
|
|
// Rule 10: every parent_id must resolve to a live item.
|
|
for _, pid := range in.ParentIDs {
|
|
if _, ok := byID[pid]; !ok {
|
|
return &ValidationError{
|
|
Kind: KindUnknownParent,
|
|
Path: in.Path,
|
|
Detail: fmt.Sprintf("parent id %q does not resolve to a live item", pid),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rules 6/7: cycle detection. Walk the ancestor closure starting from
|
|
// each proposed parent_id and look for in.ID. Cap at 64 hops to mirror
|
|
// the trigger's safety rail.
|
|
if in.ID != "" && len(in.ParentIDs) > 0 {
|
|
seen := map[string]struct{}{}
|
|
queue := append([]string{}, in.ParentIDs...)
|
|
hops := 0
|
|
for len(queue) > 0 && hops < 64 {
|
|
next := queue[0]
|
|
queue = queue[1:]
|
|
if next == in.ID {
|
|
return &ValidationError{
|
|
Kind: KindCycle,
|
|
Path: in.Path,
|
|
Detail: "the proposed parent_ids would put this item in its own ancestor closure",
|
|
}
|
|
}
|
|
if _, ok := seen[next]; ok {
|
|
continue
|
|
}
|
|
seen[next] = struct{}{}
|
|
it, ok := byID[next]
|
|
if !ok {
|
|
continue
|
|
}
|
|
queue = append(queue, it.ParentIDs...)
|
|
hops++
|
|
}
|
|
}
|
|
|
|
// Rules 8/9: slug uniqueness. For each parent_id, check whether any
|
|
// sibling under that parent already carries the same slug. Roots
|
|
// (parent_ids empty) check against other roots.
|
|
if len(in.ParentIDs) == 0 {
|
|
for _, it := range items {
|
|
if len(it.ParentIDs) != 0 {
|
|
continue
|
|
}
|
|
if it.ID == in.ID {
|
|
continue
|
|
}
|
|
if it.Slug == in.Slug {
|
|
return &ValidationError{
|
|
Kind: KindSlugCollision,
|
|
Path: in.Path,
|
|
Detail: fmt.Sprintf("a root item with slug %q already exists", in.Slug),
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
parentSet := make(map[string]struct{}, len(in.ParentIDs))
|
|
for _, pid := range in.ParentIDs {
|
|
parentSet[pid] = struct{}{}
|
|
}
|
|
for _, it := range items {
|
|
if it.ID == in.ID {
|
|
continue
|
|
}
|
|
if it.Slug != in.Slug {
|
|
continue
|
|
}
|
|
for _, sibPID := range it.ParentIDs {
|
|
if _, common := parentSet[sibPID]; common {
|
|
return &ValidationError{
|
|
Kind: KindSlugCollision,
|
|
Path: in.Path,
|
|
Detail: fmt.Sprintf("slug %q already exists under parent %q", in.Slug, sibPID),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|