mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
25d76af0ba
* fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) Every door that writes an item's fields decoded the blob into map[string]any, turning each JSON number into a float64, so the re-encode rounded integers above 2^53 (9007199254740993 stored as ...992). The stored blob was decoded that way too, so a fields_patch to `status` rewrote every large integer in the row. - models.DecodeFieldsJSON / DecodeJSONKeepingNumbers decode with UseNumber. They accept and refuse exactly what json.Unmarshal does (trailing data, out-of-float64-range numbers); only the representation changes. - Used at every fields decode that feeds a write: create, full-fields update, the fields object on the wire, fields_patch (request and store merge), the carried-blank-relation normalisation, the Append/Set metadata helpers, move, bulk move, cross-workspace copy and its preflight, and the move/copy field_overrides (models.FieldValues). - items.validateFieldType accepts a json.Number that parses as a finite number. items.coerceValue and the CLI's parseFieldFlag keep a numeric string that is a JSON number literal as that literal. - Read-only decoders are unchanged (display, hydration, guards). Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m * fix(fields): compare field values by value, so an equal number in another spelling is no change and two integers that share a float64 are (BUG-3202) Field writes now keep a number's literal, so one value can be stored as different text (1e3 vs 1000). Three places compared field values: - item.updated's change detector compared the fields blob's TEXT whenever it was not masking a string done key: an equal-value rewrite, or a key-reorder-only rewrite, emitted item.updated. - the activity `changes` diff and the BUG-3163 carry check compared float64s: equal spellings were fine, but two different integers above 2^53 compared equal, so such a change went unrecorded and a changed reserved value passed as carried. models.CanonicalJSONNumbers gives every number one exact canonical spelling. All three decode keeping literals and compare canonical forms. Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
158 lines
6.4 KiB
Go
158 lines
6.4 KiB
Go
package items
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// CoerceFields converts STRING field values to the type the collection schema
|
|
// declares, so every write door stores the same native type for the same input
|
|
// (BUG-2850).
|
|
//
|
|
// The doors did not agree. The CLI has coerced by schema type since BUG-1125
|
|
// (cmd/pad/cmd_item.go::parseFieldFlag), and local stdio MCP inherits that by
|
|
// shelling out to the binary — but the remote /mcp transport builds its field
|
|
// map in ingestFieldKVP, which stores the value half as it received it — a
|
|
// STRING — so every value arrives as a string. (Still true after BUG-2870:
|
|
// that change routed ingestFieldKVP through items.SplitFieldEntry, which
|
|
// stopped it TRIMMING the halves; it never stringified anything and does not
|
|
// type anything now either. Typing is this file's job, which is the point.) validateFieldType then correctly refuses a string
|
|
// for a declared number/json field, and the net effect was that an MCP agent on
|
|
// that transport could not write those fields AT ALL: every attempt a 400.
|
|
// Typing belongs to the server, keyed on the schema, so the doors cannot drift
|
|
// again.
|
|
//
|
|
// WHAT THIS DELIBERATELY DOES NOT DO:
|
|
//
|
|
// - It does not report errors. A value that will not coerce is left as the
|
|
// string and handed to the validator, which already produces the right
|
|
// message ("field %q must be a number"). Coercion never invents an error
|
|
// path, which is also why it cannot turn a currently-PASSING write into a
|
|
// failure — the only inputs whose behaviour changes are ones that are
|
|
// 400ing today.
|
|
// - It does not touch non-string values. A caller already sending 42 keeps
|
|
// sending 42; this is not a re-typing pass over well-formed input.
|
|
// - It does not touch keys the schema does not declare. Those are stored as
|
|
// given, silently, which is the OTHER half of BUG-2850 — see the decision
|
|
// point below.
|
|
//
|
|
// It returns a new map rather than mutating in place: two of the call sites
|
|
// re-marshal the map they pass, and a function that quietly rewrote their input
|
|
// would change what gets stored from behind a name that does not say so.
|
|
func CoerceFields(fields map[string]any, schema models.CollectionSchema) map[string]any {
|
|
if len(fields) == 0 {
|
|
return fields
|
|
}
|
|
byKey := make(map[string]models.FieldDef, len(schema.Fields))
|
|
for i := range schema.Fields {
|
|
byKey[schema.Fields[i].Key] = schema.Fields[i]
|
|
}
|
|
|
|
out := make(map[string]any, len(fields))
|
|
for k, v := range fields {
|
|
def, declared := byKey[k]
|
|
if !declared {
|
|
// DECISION POINT — BUG-2850 undeclared-key disposition (with Dave:
|
|
// refuse / warn / keep). Today's behaviour is KEEP, which is what
|
|
// silently stores materials_cost="42" on a collection that never
|
|
// declared it. Refuse becomes a returned issue here; warn becomes a
|
|
// collected key. Deliberately isolated to this branch so the choice
|
|
// drops in without touching the coercion above it.
|
|
out[k] = v
|
|
continue
|
|
}
|
|
out[k] = coerceValue(def, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// coerceValue converts one string value to its declared type, or returns it
|
|
// unchanged when it is not a string or will not parse.
|
|
func coerceValue(def models.FieldDef, v any) any {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return v
|
|
}
|
|
switch def.Type {
|
|
// `multi_relation` joins the JSON-parsed types (U4). Every string-carrying
|
|
// transport — the CLI's `--field k=v`, a form post, a query filter — hands
|
|
// this function the raw text, and the only way to express an ordered list
|
|
// in one of those is a JSON array. Without this entry `--field
|
|
// 'members=["PEOPL-1"]'` arrived as the STRING `["PEOPL-1"]` and the
|
|
// validator refused it for a shape the caller never sent, which made the
|
|
// type unwritable from the CLI entirely (codex round 1).
|
|
case "json", "multi_select", "multi_relation":
|
|
var parsed any
|
|
if err := models.DecodeJSONKeepingNumbers([]byte(s), &parsed); err == nil {
|
|
return parsed
|
|
}
|
|
case "number":
|
|
// A string that is a valid JSON number literal is kept AS that
|
|
// literal (BUG-3202): `--field n=9007199254740993` arrives here as
|
|
// text, and a float64 would store 9007199254740992. Anything else
|
|
// ParseFloat accepts (" 42", "0x1p-2", "Inf") still goes through
|
|
// the float path below, unchanged. cmd/pad's parseFieldFlag applies
|
|
// the same rule client-side and is kept in step with this one.
|
|
if models.IsJSONNumberLiteral(s) {
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) {
|
|
return json.Number(s)
|
|
}
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
// Reject NaN / ±Inf rather than storing them: encoding/json cannot
|
|
// marshal either, and the downstream json.Marshal(fields) error is
|
|
// ignored, so a non-finite float silently drops the ENTIRE fields
|
|
// payload. Falling through leaves the string for the validator,
|
|
// which says "must be a number" — the same reasoning as the CLI's
|
|
// guard in parseFieldFlag (BUG-1125).
|
|
if !math.IsNaN(f) && !math.IsInf(f, 0) {
|
|
return f
|
|
}
|
|
}
|
|
case "checkbox":
|
|
if b, err := strconv.ParseBool(s); err == nil {
|
|
return b
|
|
}
|
|
}
|
|
// text, url, select, date, relation — a string is already the right type.
|
|
// Anything that did not parse above also lands here, on purpose.
|
|
return s
|
|
}
|
|
|
|
// UndeclaredFieldKeys returns the keys of fields the schema does not declare,
|
|
// sorted, excluding system-written metadata (BUG-2850).
|
|
//
|
|
// These keys are stored, not refused — see ItemWriteWarnings for why. The
|
|
// caller reports them so a typo leaves a trace: once written, a mistyped key
|
|
// and a deliberate extra field look identical, and the reporter's case was an
|
|
// agent that could not tell its own data had gone somewhere unintended.
|
|
//
|
|
// Reserved keys are excluded via models.IsReservedItemField rather than a
|
|
// second list here — that set exists precisely so callers ask instead of
|
|
// re-listing, and its own doc comment records what re-listing cost last time.
|
|
func UndeclaredFieldKeys(fields map[string]any, schema models.CollectionSchema) []string {
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
declared := make(map[string]struct{}, len(schema.Fields))
|
|
for i := range schema.Fields {
|
|
declared[schema.Fields[i].Key] = struct{}{}
|
|
}
|
|
var out []string
|
|
for k := range fields {
|
|
if _, ok := declared[k]; ok {
|
|
continue
|
|
}
|
|
if models.IsReservedItemField(k) {
|
|
continue
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|