A non-blank document name is the first snapshot title, with restamp left false so a reprint keeps the first job. A blank name stays digest-only.
341 lines
7.6 KiB
Go
341 lines
7.6 KiB
Go
// Package metadatahist builds verae-meta-git/1 documents the same way as
|
|
// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete
|
|
// fields. A document that already uses the schema is stored as sent.
|
|
package metadatahist
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
const Schema = "verae-meta-git/1"
|
|
|
|
func IsBlank(value any) bool {
|
|
if value == nil {
|
|
return true
|
|
}
|
|
switch v := value.(type) {
|
|
case string:
|
|
for _, r := range v {
|
|
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case []any:
|
|
return len(v) == 0
|
|
case map[string]any:
|
|
return len(v) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isObject(value any) (map[string]any, bool) {
|
|
m, ok := value.(map[string]any)
|
|
return m, ok
|
|
}
|
|
|
|
func clone(value any) any {
|
|
switch v := value.(type) {
|
|
case []any:
|
|
out := make([]any, len(v))
|
|
for i := range v {
|
|
out[i] = clone(v[i])
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
out := make(map[string]any, len(v))
|
|
for k, item := range v {
|
|
out[k] = clone(item)
|
|
}
|
|
return out
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
func jsonScalar(value any) string {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
_ = enc.Encode(value)
|
|
b := bytes.TrimRight(buf.Bytes(), "\n")
|
|
return string(b)
|
|
}
|
|
|
|
func canonical(value any) string {
|
|
switch v := value.(type) {
|
|
case []any:
|
|
parts := make([]string, len(v))
|
|
for i := range v {
|
|
parts[i] = canonical(v[i])
|
|
}
|
|
return "[" + join(parts) + "]"
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(v))
|
|
for k := range v {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
parts[i] = jsonScalar(k) + ":" + canonical(v[k])
|
|
}
|
|
return "{" + join(parts) + "}"
|
|
default:
|
|
return jsonScalar(value)
|
|
}
|
|
}
|
|
|
|
func join(parts []string) string {
|
|
out := ""
|
|
for i, p := range parts {
|
|
if i > 0 {
|
|
out += ","
|
|
}
|
|
out += p
|
|
}
|
|
return out
|
|
}
|
|
|
|
func same(a, b any) bool { return canonical(a) == canonical(b) }
|
|
|
|
func ExtendSnapshot(base, patch map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
if base != nil {
|
|
if cloned, ok := clone(base).(map[string]any); ok {
|
|
out = cloned
|
|
}
|
|
}
|
|
if patch == nil {
|
|
return out
|
|
}
|
|
for key, next := range patch {
|
|
if IsBlank(next) {
|
|
continue
|
|
}
|
|
prev, has := out[key]
|
|
prevObj, prevIsObj := isObject(prev)
|
|
nextObj, nextIsObj := isObject(next)
|
|
prevArr, prevIsArr := prev.([]any)
|
|
nextArr, nextIsArr := next.([]any)
|
|
switch {
|
|
case has && prevIsObj && nextIsObj:
|
|
out[key] = ExtendSnapshot(prevObj, nextObj)
|
|
case prevIsArr && nextIsArr:
|
|
extra := nonBlank(nextArr)
|
|
if len(extra) > 0 {
|
|
out[key] = append(append([]any{}, prevArr...), extra...)
|
|
}
|
|
case nextIsArr:
|
|
extra := nonBlank(nextArr)
|
|
if len(extra) == 0 {
|
|
continue
|
|
}
|
|
if prevIsArr {
|
|
out[key] = append(append([]any{}, prevArr...), extra...)
|
|
} else {
|
|
out[key] = extra
|
|
}
|
|
case nextIsObj:
|
|
nestedBase := map[string]any{}
|
|
if prevIsObj {
|
|
nestedBase = prevObj
|
|
}
|
|
nested := ExtendSnapshot(nestedBase, nextObj)
|
|
if !IsBlank(nested) {
|
|
out[key] = nested
|
|
}
|
|
default:
|
|
out[key] = clone(next)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nonBlank(items []any) []any {
|
|
out := make([]any, 0, len(items))
|
|
for _, item := range items {
|
|
if !IsBlank(item) {
|
|
out = append(out, clone(item))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ChangesBetween(before, after map[string]any) map[string]any {
|
|
added := map[string]any{}
|
|
extended := map[string]any{}
|
|
updated := map[string]any{}
|
|
if before == nil {
|
|
before = map[string]any{}
|
|
}
|
|
if after == nil {
|
|
after = map[string]any{}
|
|
}
|
|
keys := map[string]struct{}{}
|
|
for k := range before {
|
|
keys[k] = struct{}{}
|
|
}
|
|
for k := range after {
|
|
keys[k] = struct{}{}
|
|
}
|
|
ordered := make([]string, 0, len(keys))
|
|
for k := range keys {
|
|
ordered = append(ordered, k)
|
|
}
|
|
sort.Strings(ordered)
|
|
for _, key := range ordered {
|
|
next, hasNext := after[key]
|
|
if !hasNext {
|
|
continue
|
|
}
|
|
prev, hasPrev := before[key]
|
|
if !hasPrev {
|
|
added[key] = clone(next)
|
|
continue
|
|
}
|
|
if same(prev, next) {
|
|
continue
|
|
}
|
|
prevObj, prevIsObj := isObject(prev)
|
|
nextObj, nextIsObj := isObject(next)
|
|
prevArr, prevIsArr := prev.([]any)
|
|
nextArr, nextIsArr := next.([]any)
|
|
switch {
|
|
case prevIsObj && nextIsObj:
|
|
nested := ChangesBetween(prevObj, nextObj)
|
|
if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) {
|
|
extended[key] = nested
|
|
}
|
|
case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr):
|
|
appended := nextArr[len(prevArr):]
|
|
if len(appended) > 0 {
|
|
extended[key] = map[string]any{"appended": clone(appended)}
|
|
}
|
|
default:
|
|
updated[key] = map[string]any{"from": clone(prev), "to": clone(next)}
|
|
}
|
|
}
|
|
return map[string]any{"added": added, "extended": extended, "updated": updated}
|
|
}
|
|
|
|
func commitID(commit map[string]any) string {
|
|
material := canonical(map[string]any{
|
|
"parent": commit["parent"],
|
|
"at": commit["at"],
|
|
"message": commit["message"],
|
|
"snapshot": commit["snapshot"],
|
|
})
|
|
sum := sha256.Sum256([]byte(material))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func IsHistory(value any) bool {
|
|
m, ok := isObject(value)
|
|
if !ok || m["schema"] != Schema {
|
|
return false
|
|
}
|
|
if _, ok := m["commits"].([]any); !ok {
|
|
return false
|
|
}
|
|
_, ok = isObject(m["snapshot"])
|
|
return ok
|
|
}
|
|
|
|
func historyFromSnapshot(snapshot map[string]any, at string) map[string]any {
|
|
stored, _ := clone(snapshot).(map[string]any)
|
|
commit := map[string]any{
|
|
"parent": nil,
|
|
"at": at,
|
|
"message": "stamp",
|
|
"snapshot": stored,
|
|
"changes": ChangesBetween(map[string]any{}, stored),
|
|
}
|
|
commit["id"] = commitID(commit)
|
|
return map[string]any{
|
|
"schema": Schema,
|
|
"head": commit["id"],
|
|
"snapshot": clone(stored),
|
|
"commits": []any{commit},
|
|
}
|
|
}
|
|
|
|
func coerceHistory(existing any, at string) map[string]any {
|
|
if IsHistory(existing) {
|
|
cloned, _ := clone(existing).(map[string]any)
|
|
return cloned
|
|
}
|
|
if m, ok := isObject(existing); ok && !IsBlank(m) {
|
|
return historyFromSnapshot(m, at)
|
|
}
|
|
return map[string]any{
|
|
"schema": Schema,
|
|
"head": nil,
|
|
"snapshot": map[string]any{},
|
|
"commits": []any{},
|
|
}
|
|
}
|
|
|
|
func hasChanges(changes map[string]any) bool {
|
|
return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"])
|
|
}
|
|
|
|
// Append builds the next history document. A nil result means the caller
|
|
// should omit metadata. restamp with a blank patch still appends one commit.
|
|
func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any {
|
|
if at == "" {
|
|
at = time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|
|
blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch))
|
|
if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) {
|
|
return nil
|
|
}
|
|
if priorAt == "" {
|
|
priorAt = at
|
|
}
|
|
doc := coerceHistory(existing, priorAt)
|
|
head, _ := doc["head"].(string)
|
|
if !restamp && blankPatch {
|
|
if head != "" {
|
|
return doc
|
|
}
|
|
return nil
|
|
}
|
|
base, _ := doc["snapshot"].(map[string]any)
|
|
snapshot := ExtendSnapshot(base, patch)
|
|
changes := ChangesBetween(base, snapshot)
|
|
if !restamp && !hasChanges(changes) && head != "" {
|
|
return doc
|
|
}
|
|
msg := message
|
|
if msg == "" {
|
|
if head != "" {
|
|
msg = "restamp"
|
|
} else {
|
|
msg = "stamp"
|
|
}
|
|
}
|
|
var parent any
|
|
if head != "" {
|
|
parent = head
|
|
}
|
|
commit := map[string]any{
|
|
"parent": parent,
|
|
"at": at,
|
|
"message": msg,
|
|
"snapshot": snapshot,
|
|
"changes": changes,
|
|
}
|
|
commit["id"] = commitID(commit)
|
|
commits, _ := doc["commits"].([]any)
|
|
doc["commits"] = append(commits, commit)
|
|
doc["head"] = commit["id"]
|
|
doc["snapshot"] = snapshot
|
|
return doc
|
|
}
|