Preserve tier sequence operations in source history

This commit is contained in:
2026-10-24 00:01:46 +00:00
parent e6598810e0
commit a035df9845
5 changed files with 198 additions and 7 deletions

View File

@@ -234,7 +234,7 @@ func applyHistoryApplication(root **document.Node, application historyApplicatio
mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequencesByPath)
return
}
mergeHistoryAtPath(root, application.path, application.value, index)
mergeHistoryAtPath(root, application.path, application.value, index, application.sequencesByPath)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
@@ -351,9 +351,9 @@ func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []s
return cloned
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex, sequences map[string]sequenceHistoryContributionIndex) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming, index)
mergeHistoryDocumentAtPath(root, incoming, Path{}, index, sequences)
return
}
current := *root
@@ -364,7 +364,7 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
return
}
if segmentIndex == len(path.segments)-1 {
mergeHistoryDocument(&current.Sequence[position], incoming, index)
mergeHistoryDocumentAtPath(&current.Sequence[position], incoming, Path{}, index, sequences)
return
}
current = current.Sequence[position]
@@ -378,7 +378,7 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
if position < 0 {
index.append(current, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
} else {
mergeHistoryDocument(&current.Mapping[position].Value, incoming, index)
mergeHistoryDocumentAtPath(&current.Mapping[position].Value, incoming, Path{}, index, sequences)
}
return
}

View File

@@ -78,6 +78,71 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
}
}
func TestTierSequenceHistoryReplaysSelectedOperation(t *testing.T) {
t.Parallel()
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{
selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"},
}})
for _, test := range []struct {
name string
mode sequenceMode
}{{"append", sequenceModeAppend}, {"upsert", sequenceModeUpsert}, {"replace", sequenceModeReplace}} {
t.Run(test.name, func(t *testing.T) {
mode := test.mode
lower, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "lower", "records: [{id: a, removed: old}, {id: b, value: old, removed: old}]\n"))
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
secondID := "d"
if mode == sequenceModeUpsert {
secondID = "b"
}
declarations := []sequenceDeclarationSpec{{destination: Keys("records"), modePresent: true, mode: mode}}
higher, diagnostics := composeDocumentsWithCollectionPolicy(policy, lower.collectionPolicies, declarations,
parseCompositionDocument(t, "first-peer", "records: [{id: c, value: first}]\n"),
parseCompositionDocument(t, "second-peer", "records: [{id: "+secondID+", value: second}]\n"),
)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
assembly := newAssemblyAccumulator(lower)
if diagnostics := assembly.applyOverlayWithCollectionPolicy(higher.root, higher.origins, higher.collectionPolicies, higher.applications, LayerBase, policy, declarations); len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
if len(assembly.current.applications) != 3 {
t.Fatalf("source applications = %d, want all three sources", len(assembly.current.applications))
}
if mode == sequenceModeUpsert {
query := Keys("records").Index(1).Key("value")
if _, relevant := applicationOperationPath(assembly.current.applications[1], query, nil); relevant {
t.Fatal("first peer was attributed to another peer's upserted record")
}
origin := historyApplicationOrigin(assembly.current.applications[2], query, nil)
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "second-peer" {
t.Fatalf("upsert history source = %#v", origin)
}
}
working := parseCompositionDocument(t, "initial", "{}\n")
index := new(mappingIndex)
for _, application := range assembly.current.applications {
applyHistoryApplication(&working, application, index)
}
if !configurationValuesEqual(working, assembly.current.root) {
t.Fatal("tier history differs from structural assembly before decoding")
}
overlay := parseCompositionDocument(t, "overlay", "records: [{id: c, value: overlay}]\n")
declarations[0].mode = sequenceModeUpsert
if diagnostics := assembly.applyOverlayWithCollectionPolicy(overlay, nil, collectionPolicyScope{}, nil, LayerOverlay, policy, declarations); len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
applyHistoryApplication(&working, assembly.current.applications[3], index)
if !configurationValuesEqual(working, assembly.current.root) {
t.Fatal("subsequent overlay history differs from structural assembly")
}
})
}
}
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
@@ -151,6 +216,49 @@ func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
if !present || value.Text != "new" {
t.Fatalf("indexed helper replay = %#v, %v", value, present)
}
root = parseCompositionDocument(t, "base", "records: [{id: one, helper: {items: [{id: a}]}}]\n")
application.value = parseCompositionDocument(t, "helper", "items: [{id: b}]\n")
application.sequences = []sequenceHistoryContribution{{path: Keys("items"), mode: sequenceHistoryAppend, offset: 1, length: 1}}
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
applyHistoryApplication(&root, application, new(mappingIndex))
items, present := documentAtPath(root, Keys("records").Index(0).Key("helper").Key("items"))
if !present || len(items.Sequence) != 2 {
t.Fatalf("indexed helper lost its local sequence operation: %#v", items)
}
}
func TestLayeredUpsertExplanationFollowsStructuralOrder(t *testing.T) {
t.Parallel()
type config struct {
Records []map[string]any `yaml:"records"`
}
loaded, err := testGroupedLoad(loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("configkit:\n sequences: [{destination: [records], key: id}]\nrecords: [{id: b, value: new-b}, {id: c, value: new-c}, {id: a, value: new-a}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("records: [{id: a, value: old-a}, {id: b, value: old-b, removed: old}]\n"))}}}},
})
if err != nil {
t.Fatal(err)
}
explanation, ok := loaded.Explain(Keys("records"))
if !ok || len(explanation.History) == 0 {
t.Fatal("missing collection history")
}
for _, change := range explanation.History {
if change.Origin.Operation == ChangeDecoding {
t.Fatal("structural upsert was misreported as a decoding reconciliation")
}
}
if last := explanation.History[len(explanation.History)-1]; last.After.YAML != explanation.Effective.YAML || last.Origin.Layer != LayerBase {
t.Fatalf("main contribution did not produce the effective collection: %#v", last)
}
removed, ok := loaded.Explain(Keys("records").Index(1).Key("removed"))
if !ok || len(removed.History) == 0 || removed.Effective.Present {
t.Fatalf("missing displaced field history: %#v, %v", removed, ok)
}
if last := removed.History[len(removed.History)-1]; last.After.Present || last.Origin.Layer != LayerBase || last.Origin.Operation == ChangeDecoding {
t.Fatalf("upsert removal attribution = %#v", last)
}
}
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) {

View File

@@ -78,7 +78,7 @@ func (a *assemblyAccumulator) applyOverlayWithCollectionPolicy(
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
a.current.applications = append(a.current.applications, application)
} else {
for _, application := range applications {
for _, application := range projectTierHistoryApplications(applications, sequences) {
application.layer = layer
a.current.applications = append(a.current.applications, application)
}

View File

@@ -0,0 +1,76 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// projectTierHistoryApplications composes each strict source's local projection
// with the tier operation. The retained source values stay in authored coordinates.
func projectTierHistoryApplications(applications []historyApplication, operations []sequenceHistoryContribution) []historyApplication {
mapped := false
for _, operation := range operations {
mapped = mapped || operation.mode == sequenceHistoryMapped
}
if !mapped {
return applications
}
projections := indexSequenceHistoryContributions(operations)
projected := cloneHistoryApplications(applications)
for index, original := range applications {
application := &projected[index]
if path, found := projectSequenceHistoryPath(original.path, projections); found {
application.path = path
}
for sequenceIndex, source := range original.sequences {
container := appendDestination(original.path, source.path)
operation, active := projections[container.String()]
if !active || operation.mode != sequenceHistoryMapped {
continue
}
value, found := documentAtPath(original.value, source.path)
if !found || value.Kind != document.Sequence {
continue
}
sourceIndex := application.sequencesByPath[source.path.String()]
contribution := sequenceHistoryContribution{path: source.path.clone(), mode: sequenceHistoryMapped}
for inputIndex := range value.Sequence {
tierIndex := source.offset + inputIndex
if source.mode == sequenceHistoryMapped {
tierIndex = sourceIndex.outputForInput(inputIndex)
}
outputIndex, present := operation.projection.outputForInput(tierIndex)
if !present {
continue
}
contribution.items = append(contribution.items, sequenceItemProjection{inputIndex: inputIndex, outputIndex: outputIndex})
if _, replaced := operation.replacedOutputs[outputIndex]; replaced {
// Upsert replacements retain their lower-list position.
contribution.replacements = append(contribution.replacements, sequenceItemReplacement{
lowerIndex: outputIndex, higherIndex: inputIndex, outputIndex: outputIndex,
})
}
}
application.sequences[sequenceIndex] = contribution
}
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
}
return projected
}
func projectSequenceHistoryPath(path Path, projections map[string]sequenceHistoryContributionIndex) (Path, bool) {
projected := path.clone()
for index, segment := range path.segments {
if segment.kind != pathSegmentIndex {
continue
}
prefix := Path{segments: path.segments[:index]}
operation, found := projections[prefix.String()]
if !found || operation.mode != sequenceHistoryMapped {
continue
}
outputIndex, found := operation.projection.outputForInput(segment.index)
if !found {
return Path{}, false
}
projected.segments[index].index = outputIndex
}
return projected, true
}