Files
pad/internal/models/fields_json.go
T
xarmian 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) (#1545)
* 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
2026-09-25 14:22:09 -04:00

197 lines
6.5 KiB
Go

package models
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// DecodeFieldsJSON decodes an item's fields blob with every number kept as the
// literal it was written as (a json.Number), for code that decodes a blob in
// order to WRITE it back (BUG-3202).
//
// A plain json.Unmarshal into map[string]any turns each number into a float64,
// so the re-encode rounds an integer above 2^53 (9007199254740993 is stored
// back as 9007199254740992) and rewrites number formatting. Every field write
// door decoded the stored blob that way, so a write rounded numbers it never
// named: a fields_patch to `status` rewrote every large integer in the row.
// json.Number marshals back as its own literal, so a decode-then-encode leaves
// the numbers it did not change byte-identical.
//
// It accepts and refuses exactly what json.Unmarshal into map[string]any
// does. Trailing data after the value is refused, as Unmarshal refuses it,
// and so is a number that does not fit a float64 (1e400), which Unmarshal
// refuses with a range error. Only the REPRESENTATION of an accepted number
// changes, never the set of blobs a door accepts.
//
// A literal `null` decodes to a nil map with no error, as it does through
// Unmarshal; callers keep their existing handling of that case.
func DecodeFieldsJSON(raw []byte) (map[string]any, error) {
var m map[string]any
if err := DecodeJSONKeepingNumbers(raw, &m); err != nil {
return nil, err
}
return m, nil
}
// DecodeJSONKeepingNumbers is DecodeFieldsJSON for any target: v must be a
// pointer, as for json.Unmarshal. The range check covers numbers held in
// interface{} slots (any, map[string]any, []any), which are the only slots
// UseNumber affects; a typed float64 or int destination is range-checked by
// the decoder itself, as it always was.
func DecodeJSONKeepingNumbers(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(v); err != nil {
return err
}
if _, err := dec.Token(); err != io.EOF {
return fmt.Errorf("invalid JSON: trailing data after the top-level value")
}
return checkNumbersFitFloat64(v)
}
// checkNumbersFitFloat64 refuses a json.Number that json.Unmarshal would have
// refused when decoding into an interface: one ParseFloat reports out of
// range.
func checkNumbersFitFloat64(v any) error {
switch x := v.(type) {
case *map[string]any:
if x != nil {
return checkNumbersFitFloat64(*x)
}
case *any:
if x != nil {
return checkNumbersFitFloat64(*x)
}
case *[]any:
if x != nil {
return checkNumbersFitFloat64(*x)
}
case map[string]any:
for _, e := range x {
if err := checkNumbersFitFloat64(e); err != nil {
return err
}
}
case []any:
for _, e := range x {
if err := checkNumbersFitFloat64(e); err != nil {
return err
}
}
case json.Number:
if _, err := strconv.ParseFloat(string(x), 64); err != nil {
return fmt.Errorf("json: cannot unmarshal number %s into Go value of type float64", string(x))
}
}
return nil
}
// FieldValues is a request member carrying item field values, decoded keeping
// number literals (BUG-3202). The move and copy `field_overrides` are written
// into the destination item, so a plain map[string]any member rounded an
// integer above 2^53 the caller supplied. Absent and null decode to nil, as
// the plain map did.
type FieldValues map[string]any
func (f *FieldValues) UnmarshalJSON(data []byte) error {
m, err := DecodeFieldsJSON(data)
if err != nil {
return err
}
*f = m
return nil
}
// IsJSONNumberLiteral reports whether s is exactly one JSON number token, the
// form json.Number marshals verbatim. The field-value coercions (the server's
// items.CoerceFields and the CLI's --field typing) use it to keep a numeric
// string as its literal (BUG-3202). json.Valid alone would also accept an
// object or a quoted string, so the first byte is checked too.
func IsJSONNumberLiteral(s string) bool {
if s == "" || (s[0] != '-' && (s[0] < '0' || s[0] > '9')) {
return false
}
return json.Valid([]byte(s))
}
// CanonicalJSONNumbers returns v with every number replaced by one canonical
// spelling of its exact value, for code that COMPARES decoded field values
// (BUG-3202). Since field writes keep a number's literal, one value can be
// stored as different text (1e3, 1000, 1000.0); comparing that text reports a
// change nobody made. Comparing float64s instead has the opposite hole: two
// different integers above 2^53 round to one float and compare equal. The
// canonical form is exact and equal for equal values, so both are closed.
//
// Numbers may arrive as json.Number (from DecodeJSONKeepingNumbers) or as
// float64 (from a plain decode); both are canonicalised, so a caller comparing
// a value from each gets the numeric answer. Maps and slices are copied, never
// mutated. The result is for comparison and marshals to valid JSON, but it is
// NOT a storage form: it rewrites the caller's spelling.
func CanonicalJSONNumbers(v any) any {
switch x := v.(type) {
case map[string]any:
out := make(map[string]any, len(x))
for k, e := range x {
out[k] = CanonicalJSONNumbers(e)
}
return out
case []any:
out := make([]any, len(x))
for i, e := range x {
out[i] = CanonicalJSONNumbers(e)
}
return out
case json.Number:
return json.Number(canonicalNumberLiteral(string(x)))
case float64:
return json.Number(canonicalNumberLiteral(strconv.FormatFloat(x, 'g', -1, 64)))
}
return v
}
// canonicalNumberLiteral rewrites a JSON number literal as
// [-]<digits without leading or trailing zeros>e<exponent>, which is exact
// and the same for every spelling of one value: 1000, 1e3, 1.0e3 and 1000.0
// all become "1e3", and zero in any spelling becomes "0". A string that is not
// a number literal is returned unchanged.
func canonicalNumberLiteral(s string) string {
if !IsJSONNumberLiteral(s) {
return s
}
neg := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
mant, expPart := s, ""
if i := strings.IndexAny(s, "eE"); i >= 0 {
mant, expPart = s[:i], s[i+1:]
}
exp := 0
if expPart != "" {
e, err := strconv.Atoi(expPart)
if err != nil {
return s // an exponent too large for int: leave it; no such value fits a float64 anyway
}
exp = e
}
intPart, frac := mant, ""
if i := strings.IndexByte(mant, '.'); i >= 0 {
intPart, frac = mant[:i], mant[i+1:]
}
digits := strings.TrimLeft(intPart+frac, "0")
exp -= len(frac)
if digits == "" {
return "0"
}
trimmed := strings.TrimRight(digits, "0")
exp += len(digits) - len(trimmed)
out := trimmed + "e" + strconv.Itoa(exp)
if neg {
out = "-" + out
}
return out
}