mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
bed933d7fd
* 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
252 lines
7.8 KiB
Go
252 lines
7.8 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// decodeFields is a small test helper: parse an item's fields JSON into a map.
|
|
func decodeFields(t *testing.T, fieldsJSON string) map[string]any {
|
|
t.Helper()
|
|
m := map[string]any{}
|
|
if fieldsJSON == "" || fieldsJSON == "{}" {
|
|
return m
|
|
}
|
|
if err := json.Unmarshal([]byte(fieldsJSON), &m); err != nil {
|
|
t.Fatalf("decode fields %q: %v", fieldsJSON, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// TestMergeFieldsPatch exercises the pure shallow-merge helper directly:
|
|
// set, delete-on-null, orphan-key preservation, and empty-base handling.
|
|
func TestMergeFieldsPatch(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
current string
|
|
patch map[string]any
|
|
want map[string]any
|
|
}{
|
|
{
|
|
name: "set one key preserves the rest",
|
|
current: `{"status":"open","priority":"high"}`,
|
|
patch: map[string]any{"status": "done"},
|
|
want: map[string]any{"status": "done", "priority": "high"},
|
|
},
|
|
{
|
|
name: "null deletes a key",
|
|
current: `{"status":"open","priority":"high"}`,
|
|
patch: map[string]any{"priority": nil},
|
|
want: map[string]any{"status": "open"},
|
|
},
|
|
{
|
|
name: "add a new orphan key",
|
|
current: `{"status":"open"}`,
|
|
patch: map[string]any{"custom": "x"},
|
|
want: map[string]any{"status": "open", "custom": "x"},
|
|
},
|
|
{
|
|
name: "empty base",
|
|
current: "",
|
|
patch: map[string]any{"status": "open"},
|
|
want: map[string]any{"status": "open"},
|
|
},
|
|
{
|
|
name: "empty-object base",
|
|
current: "{}",
|
|
patch: map[string]any{"status": "open"},
|
|
want: map[string]any{"status": "open"},
|
|
},
|
|
{
|
|
name: "delete a key that isn't there is a no-op",
|
|
current: `{"status":"open"}`,
|
|
patch: map[string]any{"missing": nil},
|
|
want: map[string]any{"status": "open"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := mergeFieldsPatch(tc.current, tc.patch)
|
|
if err != nil {
|
|
t.Fatalf("mergeFieldsPatch: %v", err)
|
|
}
|
|
var gotMap map[string]any
|
|
if err := json.Unmarshal([]byte(got), &gotMap); err != nil {
|
|
t.Fatalf("merged JSON invalid: %v (%q)", err, got)
|
|
}
|
|
if len(gotMap) != len(tc.want) {
|
|
t.Fatalf("key count: got %v want %v", gotMap, tc.want)
|
|
}
|
|
for k, v := range tc.want {
|
|
if gotMap[k] != v {
|
|
t.Errorf("key %q: got %v want %v", k, gotMap[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateItemFieldsPatchMergesNotReplaces is the core race-fix regression
|
|
// (IDEA-1480 / TASK-2022): a FieldsPatch update touches ONE key and leaves the
|
|
// rest of the fields blob intact, unlike a full `fields` write which replaces
|
|
// everything.
|
|
func TestUpdateItemFieldsPatchMergesNotReplaces(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "FieldPatch")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Item",
|
|
Fields: `{"status":"open","priority":"high"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem: %v", err)
|
|
}
|
|
|
|
// Patch only status; priority must survive.
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
FieldsPatch: map[string]any{"status": "done"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem (patch): %v", err)
|
|
}
|
|
fields := decodeFields(t, updated.Fields)
|
|
if fields["status"] != "done" {
|
|
t.Errorf("status: got %v want done", fields["status"])
|
|
}
|
|
if fields["priority"] != "high" {
|
|
t.Errorf("priority clobbered: got %v want high (the whole point of field-level merge)", fields["priority"])
|
|
}
|
|
}
|
|
|
|
// TestUpdateItemFieldsPatchNullDeletes verifies the JSON-null delete sentinel
|
|
// removes a single key while preserving the others.
|
|
func TestUpdateItemFieldsPatchNullDeletes(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "FieldPatchDelete")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Item",
|
|
Fields: `{"status":"open","priority":"high"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem: %v", err)
|
|
}
|
|
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
FieldsPatch: map[string]any{"priority": nil},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem (patch delete): %v", err)
|
|
}
|
|
fields := decodeFields(t, updated.Fields)
|
|
if _, ok := fields["priority"]; ok {
|
|
t.Errorf("priority should have been deleted, got %v", fields["priority"])
|
|
}
|
|
if fields["status"] != "open" {
|
|
t.Errorf("status: got %v want open", fields["status"])
|
|
}
|
|
}
|
|
|
|
// TestUpdateItemFieldsPatchSequentialNoClobber simulates the concurrent
|
|
// single-field writes IDEA-1480 describes, but sequentially against the store:
|
|
// two independent patches (status, then a custom meta key) must BOTH survive.
|
|
// Before field-level merge, the second full-blob write would have overwritten
|
|
// the first if it had been built from a stale read.
|
|
func TestUpdateItemFieldsPatchSequentialNoClobber(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "FieldPatchSeq")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Item",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem: %v", err)
|
|
}
|
|
|
|
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
FieldsPatch: map[string]any{"status": "done"},
|
|
}); err != nil {
|
|
t.Fatalf("UpdateItem patch 1: %v", err)
|
|
}
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
FieldsPatch: map[string]any{"pad_source_url": "https://example.com"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem patch 2: %v", err)
|
|
}
|
|
fields := decodeFields(t, updated.Fields)
|
|
if fields["status"] != "done" {
|
|
t.Errorf("first patch lost: status got %v want done", fields["status"])
|
|
}
|
|
if fields["pad_source_url"] != "https://example.com" {
|
|
t.Errorf("second patch lost: pad_source_url got %v", fields["pad_source_url"])
|
|
}
|
|
}
|
|
|
|
// TestUpdateItemExpectedUpdatedAtMatch: a matching optimistic-concurrency
|
|
// token lets the update through.
|
|
func TestUpdateItemExpectedUpdatedAtMatch(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "OCCMatch")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Item", "body")
|
|
|
|
expected := item.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
|
patch := map[string]any{"status": "done"}
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
ExpectedUpdatedAt: expected,
|
|
FieldsPatch: patch,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem with matching expected_updated_at should succeed: %v", err)
|
|
}
|
|
if got := decodeFields(t, updated.Fields)["status"]; got != "done" {
|
|
t.Errorf("status: got %v want done", got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateItemExpectedUpdatedAtConflict: a stale token is rejected with
|
|
// *UpdateConflictError, and the item is left unchanged.
|
|
func TestUpdateItemExpectedUpdatedAtConflict(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "OCCConflict")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
item := createTestItem(t, s, ws.ID, col.ID, "Item", "body")
|
|
|
|
// A timestamp that definitely doesn't match the row's updated_at.
|
|
stale := "2000-01-01T00:00:00Z"
|
|
_, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
ExpectedUpdatedAt: stale,
|
|
FieldsPatch: map[string]any{"status": "done"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected UpdateConflictError, got nil")
|
|
}
|
|
var conflict *UpdateConflictError
|
|
if !errors.As(err, &conflict) {
|
|
t.Fatalf("expected *UpdateConflictError, got %T: %v", err, err)
|
|
}
|
|
if conflict.ExpectedUpdatedAt != stale {
|
|
t.Errorf("conflict.ExpectedUpdatedAt: got %q want %q", conflict.ExpectedUpdatedAt, stale)
|
|
}
|
|
if conflict.ActualUpdatedAt.IsZero() {
|
|
t.Error("conflict.ActualUpdatedAt should carry the row's real timestamp")
|
|
}
|
|
|
|
// The write must NOT have landed.
|
|
reread, err := s.GetItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItem: %v", err)
|
|
}
|
|
if got := decodeFields(t, reread.Fields)["status"]; got != "open" {
|
|
t.Errorf("conflicting update should be a no-op; status got %v want open", got)
|
|
}
|
|
}
|