package history import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" ) var ( ErrInvalidPatch = errors.New("invalid patch") ErrConflict = errors.New("history conflict") ErrNotFound = errors.New("history entity not found") ) func canonicalJSON(v any) ([]byte, error) { return json.Marshal(v) } func hashCanonical(v any) (string, []byte, error) { b, err := canonicalJSON(v) if err != nil { return "", nil, err } sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]), b, nil } func toGenericMap(v any) (map[string]any, error) { b, err := json.Marshal(v) if err != nil { return nil, err } var out map[string]any if err := json.Unmarshal(b, &out); err != nil { return nil, err } return out, nil } func fromGenericMap(m map[string]any, dest any) error { normalizeMapForCanonical(m) b, err := json.Marshal(m) if err != nil { return err } return json.Unmarshal(b, dest) } func normalizeMapForCanonical(v any) { switch t := v.(type) { case map[string]any: for k, vv := range t { if s, ok := vv.(string); ok && strings.TrimSpace(s) == "" { t[k] = nil continue } normalizeMapForCanonical(vv) } case []any: for i := range t { normalizeMapForCanonical(t[i]) } } } func applyPatch(state map[string]any, ops []PatchOp) error { for _, op := range ops { if err := applyPatchOp(state, op); err != nil { return err } } return nil } func applyPatchOp(root map[string]any, op PatchOp) error { if root == nil { return fmt.Errorf("%w: nil root", ErrInvalidPatch) } kind := strings.ToLower(strings.TrimSpace(op.Op)) if kind != "add" && kind != "replace" && kind != "remove" { return fmt.Errorf("%w: unsupported op %q", ErrInvalidPatch, op.Op) } if !strings.HasPrefix(op.Path, "/") || op.Path == "/" { return fmt.Errorf("%w: invalid path %q", ErrInvalidPatch, op.Path) } parts := parsePointer(op.Path) if len(parts) == 0 { return fmt.Errorf("%w: invalid path %q", ErrInvalidPatch, op.Path) } for _, p := range parts { if p == "" { return fmt.Errorf("%w: empty segment in %q", ErrInvalidPatch, op.Path) } if isNumeric(p) { return fmt.Errorf("%w: array paths not supported in v1 (%q)", ErrInvalidPatch, op.Path) } } parent := root for i := 0; i < len(parts)-1; i++ { segment := parts[i] next, ok := parent[segment] if !ok || next == nil { if kind == "remove" { return nil } child := map[string]any{} parent[segment] = child parent = child continue } child, ok := next.(map[string]any) if !ok { return fmt.Errorf("%w: non-object segment %q", ErrInvalidPatch, segment) } parent = child } last := parts[len(parts)-1] switch kind { case "remove": delete(parent, last) return nil case "add", "replace": parent[last] = op.Value return nil default: return fmt.Errorf("%w: unsupported op %q", ErrInvalidPatch, op.Op) } } func parsePointer(path string) []string { raw := strings.Split(strings.TrimPrefix(path, "/"), "/") out := make([]string, 0, len(raw)) for _, p := range raw { p = strings.ReplaceAll(p, "~1", "/") p = strings.ReplaceAll(p, "~0", "~") out = append(out, p) } return out } func isNumeric(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func sortedStrings(values []string) []string { out := append([]string(nil), values...) sort.Strings(out) return out }