Files
pad/internal/items/validate.go
T
xarmian bed933d7fd feat(items): field-level PATCH + conflict envelope + read-only version history (TASK-2022) (#876)
* feat(items): field-level PATCH + conflict envelope + version history

Adds three related item-update primitives (TASK-2022 / IDEA-1480):

- Field-level merge: PATCH `fields_patch` shallow-merges onto the item's
  current fields INSIDE the write transaction (null deletes a key), so
  concurrent single-field updates no longer clobber each other via the
  full-blob read-modify-write. `pad item update` and the MCP `pad_item.update`
  action now send only the changed keys.
- Optimistic concurrency: optional `expected_updated_at` on update; on
  mismatch the store returns *UpdateConflictError and the handler emits the
  pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict).
  Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`).
- Read-only version history: `pad item history <ref>` (alias `versions`) and
  MCP `pad_item.history`, reusing the existing item_versions store + versions
  endpoint (no new store, no schema change).

MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update
behavior change). No migration required.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS

* fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards

Round 1+2 review fixes for TASK-2022:
- HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only
  changed keys) instead of a client-side merged full fields blob, and forwards
  expected_updated_at — remote MCP callers get the same race-free merge +
  optimistic concurrency the CLI/HTTP paths do.
- ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field
  (would otherwise persist a blob the full-update validator rejects).
- Open-children guard on the fields_patch path merges the patch onto the IN-TX
  locked row inside the precheck (not a stale pre-lock preview), so a
  priority-only patch can't false-fire the guard.
- Optimistic-concurrency check now runs BEFORE the open-children precheck in the
  store, so a stale expected_updated_at yields update_conflict (not
  open_children) — single in-tx re-read shared by both.
- Date auto-population on the patch path only fills an EMPTY current date; an
  existing end_date the caller isn't touching is preserved.

Tests added for each fix.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:47:34 -04:00

240 lines
6.7 KiB
Go

package items
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// patternCache memoizes compiled regexes so repeat validations don't pay the
// re-compile cost. Schemas change rarely; the entries are tiny.
var (
patternCache = make(map[string]*regexp.Regexp)
patternCacheMu sync.RWMutex
)
func compilePattern(pat string) (*regexp.Regexp, error) {
patternCacheMu.RLock()
re, ok := patternCache[pat]
patternCacheMu.RUnlock()
if ok {
return re, nil
}
re, err := regexp.Compile(pat)
if err != nil {
return nil, err
}
patternCacheMu.Lock()
patternCache[pat] = re
patternCacheMu.Unlock()
return re, nil
}
// ValidateFields checks field values against the collection schema.
// It validates required fields are present, types are correct, and select
// values are within the allowed options. It applies defaults for missing
// optional fields, mutating the fields map in place.
func ValidateFields(fields map[string]any, schema models.CollectionSchema) error {
var errs []string
for _, def := range schema.Fields {
val, exists := fields[def.Key]
// Apply default if field is missing and a default is defined
if !exists || val == nil {
if def.Required {
if def.Default != nil {
fields[def.Key] = def.Default
continue
}
errs = append(errs, fmt.Sprintf("field %q is required", def.Key))
continue
}
if def.Default != nil {
fields[def.Key] = def.Default
}
continue
}
// Validate by type
if err := validateFieldType(def, val); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("field validation failed: %s", strings.Join(errs, "; "))
}
return nil
}
// ValidatePartialFields validates ONLY the keys present in `patch` against
// the schema (TASK-2022, field-level PATCH / IDEA-1480). Unlike
// ValidateFields it does NOT enforce required-field presence and does NOT
// inject schema defaults — a partial patch is "change exactly these keys,
// leave everything else alone," so absent keys are neither missing nor
// candidates for default population. Keys the schema doesn't declare (orphan
// keys) are accepted and persist unchanged, matching the full-blob path.
//
// A nil value marks a key for DELETION (see store.mergeFieldsPatch); those
// are skipped here since there's no value to type-check.
func ValidatePartialFields(patch map[string]any, schema models.CollectionSchema) error {
// Index declared fields by key for O(1) lookup.
defByKey := make(map[string]models.FieldDef, len(schema.Fields))
for _, def := range schema.Fields {
defByKey[def.Key] = def
}
var errs []string
for key, val := range patch {
def, hasDef := defByKey[key]
if val == nil {
// Deletion sentinel. Refuse to delete a schema-declared REQUIRED
// field — the full-update path (ValidateFields) would reject the
// resulting blob (or re-default it), so allowing a patch to strip
// it would persist a state the schema considers invalid. Orphan
// keys and optional fields delete freely.
if hasDef && def.Required {
errs = append(errs, fmt.Sprintf("field %q is required and cannot be deleted", key))
}
continue
}
if !hasDef {
// Orphan key (not in schema) — allowed, persists as-is.
continue
}
if err := validateFieldType(def, val); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("field validation failed: %s", strings.Join(errs, "; "))
}
return nil
}
func validateFieldType(def models.FieldDef, val any) error {
switch def.Type {
case "text", "url":
if _, ok := val.(string); !ok {
return fmt.Errorf("field %q must be a string", def.Key)
}
case "number":
switch val.(type) {
case float64, int, int64, float32:
// ok
default:
return fmt.Errorf("field %q must be a number", def.Key)
}
case "checkbox":
if _, ok := val.(bool); !ok {
return fmt.Errorf("field %q must be a boolean", def.Key)
}
case "date":
s, ok := val.(string)
if !ok {
return fmt.Errorf("field %q must be a date string (ISO 8601)", def.Key)
}
if s != "" {
// Accept YYYY-MM-DD or full RFC3339
if _, err := time.Parse("2006-01-02", s); err != nil {
if _, err := time.Parse(time.RFC3339, s); err != nil {
return fmt.Errorf("field %q has invalid date format (expected YYYY-MM-DD or RFC3339)", def.Key)
}
}
}
case "select":
s, ok := val.(string)
if !ok {
return fmt.Errorf("field %q must be a string", def.Key)
}
if s != "" && len(def.Options) > 0 {
found := false
for _, opt := range def.Options {
if opt == s {
found = true
break
}
}
if !found {
return fmt.Errorf("field %q value %q is not in allowed options %v", def.Key, s, def.Options)
}
}
case "multi_select":
// Accept a slice of strings
switch v := val.(type) {
case []any:
for i, item := range v {
s, ok := item.(string)
if !ok {
return fmt.Errorf("field %q item %d must be a string", def.Key, i)
}
if len(def.Options) > 0 {
found := false
for _, opt := range def.Options {
if opt == s {
found = true
break
}
}
if !found {
return fmt.Errorf("field %q value %q is not in allowed options %v", def.Key, s, def.Options)
}
}
}
case []string:
for _, s := range v {
if len(def.Options) > 0 {
found := false
for _, opt := range def.Options {
if opt == s {
found = true
break
}
}
if !found {
return fmt.Errorf("field %q value %q is not in allowed options %v", def.Key, s, def.Options)
}
}
}
default:
return fmt.Errorf("field %q must be an array of strings", def.Key)
}
case "relation":
if _, ok := val.(string); !ok {
return fmt.Errorf("field %q must be a string (item ID)", def.Key)
}
case "json":
// Accept only structured JSON values (object, array, null). Raw
// strings / numbers / bools are rejected so a generic text input in
// the UI can't silently corrupt a structured field (e.g. emitting
// the string "[]" instead of an actual array). Callers that want a
// scalar field should use "text", "number", or "checkbox".
switch val.(type) {
case map[string]any, []any, nil:
// ok
default:
return fmt.Errorf("field %q must be a JSON object, array, or null", def.Key)
}
}
// Pattern check applies to string-typed values (text, url, and JSON strings).
if def.Pattern != "" {
s, ok := val.(string)
if ok && s != "" {
re, err := compilePattern(def.Pattern)
if err != nil {
return fmt.Errorf("field %q has an invalid pattern in its schema: %v", def.Key, err)
}
if !re.MatchString(s) {
return fmt.Errorf("field %q value %q does not match required pattern %q", def.Key, s, def.Pattern)
}
}
}
return nil
}