Files
core/internal/history/patch.go

165 lines
3.4 KiB
Go

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
}