diff --git a/docs/config.md b/docs/config.md index a92ff24..6c97a3d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -71,8 +71,8 @@ missing file remains an error; it is never interpreted as no input. `Options` accepts the application definition, ordinary-source `Destination` and `PlacementMode`, explicit imports, one selected overlay, filesystem context, explicit CLI overrides, and shared source-version controls. The definition -may register strict sequence composition paths through -`SequenceCompositions`. Its zero-valued placement mode places the source mapping +may register sequence identities through `Definition.SequenceIdentities`. Its +zero-valued placement mode places the source mapping at the destination; `sequence-item` explicitly wraps the mapping as one item at a non-root destination. Unknown modes, invalid destinations, and invalid or conflicting sequence registrations fail before source acquisition. Overlay and @@ -811,33 +811,39 @@ validated, but there are no imported peers to combine. | Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` | Every non-mapping overlap is a conflict, even when values are equal, except for -sequence/sequence contributions at a registered sequence-composition path or a -source-declared collection destination. Nulls remain atomic conflicts. +sequence/sequence contributions at a registered sequence identity destination +or a source-declared collection destination. Nulls remain atomic conflicts. Unregistered and undeclared sequences conflict when repeated and are never joined by index. Disjoint entries under shared mappings compose, making stable keyed mappings the natural representation for independently contributed entries. -`Definition.SequenceCompositions` registers literal mapping paths and one literal -`IdentityField` for sequences that should compose independently authored records. -Registration paths follow input-destination key rules: parsed numeric tokens are -literal mapping keys, explicit index segments and reserved `configkit` or -mapping-valued `extends` segments are invalid, and the root registration is -harmless and unused. The identity field is one literal string key; neither the -field name nor identity strings are normalized. -At each registered path, every actual sequence contribution in a strict tier is -validated, including a singleton contribution: each record must be a mapping with -a present string identity field. Distinct exact identity strings append in source -and list order; duplicate identities conflict even if the records are otherwise -equal or disjoint. Empty identity strings are valid. Malformed records produce -`compose.invalid`; duplicate identities produce `compose.conflict`, with the -sequence container and source locations identifying a duplicate without exposing -identity values. Missing paths and non-sequence values do not fail by themselves. -Identical registrations coalesce; conflicting registrations at one path and -invalid registration paths produce `options.invalid`. Unused and root registrations -are allowed and have no effect. +`Definition.SequenceIdentities` contains registrations created by +`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a +mapping-only `Path`; the matching form accepts literal-key and any-mapping-key +segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal +`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens +remain literal mapping keys; explicit index segments and reserved `configkit` or +mapping-valued `extends` segments are invalid. The root selector is harmless under +the mapping-root schema. Each registration requires one or more literal field +names; compound field sets are normalized as sets, and field names are not +otherwise normalized. + +At each selected path, records must provide every registered field as a scalar +string, number, boolean, or explicit null. The type-tagged tuple preserves +category distinctions, while equivalent finite decimal spellings such as `1`, +`1.0`, and `1e0` compare as the same number. Distinct tuples append in source and +list order; duplicate tuples conflict even when records are otherwise equal or +disjoint. Malformed records produce `compose.invalid`; duplicate identities +produce `compose.conflict`, with paths and source locations identifying a +duplicate without exposing identity values. Missing paths and non-sequence values +do not fail by themselves. Equivalent overlapping registrations coalesce; +incompatible overlaps and invalid registrations produce `options.invalid` before +source acquisition. Unused and root registrations are allowed and have no effect. Registration controls strict composition and supplies identity fields for parent -lookup when surviving records in that sequence use `extends`. Each underlay group +lookup when surviving records in that sequence use `extends`. Dynamic selectors +apply at strict-composition and inheritance destinations without requiring the +application to enumerate mapping keys. Each underlay group and the main tier has independent duplicate-identity state. Precedence assembly is unchanged: a present higher-priority sequence replaces the lower sequence as one value, including at a registered path. Registration does not add a final @@ -1092,7 +1098,7 @@ including every match of a glob; placement is not inherited. Sequence-item placement requires a nonempty effective destination and wraps the imported mapping as one element of a sequence there. It does not split source sequences or infer identity fields. Repeated sequences still conflict unless their path is -registered through `Definition.SequenceCompositions`; registration does not +registered through `Definition.SequenceIdentities`; registration does not change placement or precedence behavior. Sequence records retain source locations through placement and history; indexes describe their assembled positions while source coordinates continue to identify the declaring files. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5bf6ea6..96c4f24 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–21 complete. +Status: implementation in progress; Stages 1–22 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -760,6 +760,11 @@ Scope: the settled facade surface and migration of in-repository callers. Validation: root sequence/facade, registration, public/dependency-boundary, and consumer example tests plus the everyday check. +**Completion:** Exposed the root-owned exact and dynamic sequence identity +registration API, migrated maintained callers, and removed the old single-field +registration adapters. Verified facade, registration, inheritance, +public/dependency-boundary, and consumer example tests plus `./scripts/check.sh`. + ## Stage 23 — Separate Reusable CLI Override Preparation Scope: mechanical preparation refactor and a privately tested post-structural diff --git a/examples/consumers/prompt_catalog.go b/examples/consumers/prompt_catalog.go index b2f7acb..f452235 100644 --- a/examples/consumers/prompt_catalog.go +++ b/examples/consumers/prompt_catalog.go @@ -45,10 +45,9 @@ func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalog func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] { return configkit.Definition[PromptCatalogConfig]{ - SequenceCompositions: []configkit.SequenceComposition{{ - Path: configkit.Keys("prompts"), - IdentityField: "id", - }}, + SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), + }, Validate: validatePromptCatalog, } } diff --git a/facade_import_lifecycle_test.go b/facade_import_lifecycle_test.go index 63e25ad..601dcf4 100644 --- a/facade_import_lifecycle_test.go +++ b/facade_import_lifecycle_test.go @@ -40,8 +40,8 @@ entries: Imports: true, Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem}, Definition: Definition[map[string]any]{ - Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, - SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "id"}}, + Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, + SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")}, Secret: func(candidate Path) bool { return candidate.String() == "/services/records/0/version" }, @@ -105,8 +105,8 @@ entries: Imports: true, Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem}, Definition: Definition[map[string]any]{ - Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, - SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "version"}}, + Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}, + SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")}, Secret: func(candidate Path) bool { return candidate.String() == "/services/records/0/version" }, diff --git a/facade_inheritance_test.go b/facade_inheritance_test.go index 8647b8e..9d67d1e 100644 --- a/facade_inheritance_test.go +++ b/facade_inheritance_test.go @@ -133,12 +133,12 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin "config/identified.yml": &fstest.MapFile{Data: input}, "config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")}, }) - registrations := []configkit.SequenceComposition{ - {Path: configkit.Keys("jobs"), IdentityField: "id"}, + registrations := []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"), } result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{ Definition: configkit.Definition[configuration]{ - SequenceCompositions: registrations, + SequenceIdentities: registrations, Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential" }, @@ -220,8 +220,8 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing. `) _, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{ Definition: configkit.Definition[configuration]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("jobs"), IdentityField: "id"}}, - Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" }, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")}, + Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" }, }, }) if err == nil || strings.Contains(err.Error(), canary) { diff --git a/facade_sequence_identity_test.go b/facade_sequence_identity_test.go new file mode 100644 index 0000000..1c88a36 --- /dev/null +++ b/facade_sequence_identity_test.go @@ -0,0 +1,240 @@ +package configkit_test + +import ( + "errors" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) { + fields := []string{"namespace", "id", "id"} + selector := configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), + configkit.SequenceAnyMappingKey(), + configkit.SequenceLiteralKey("prompts"), + } + identity := configkit.SequenceIdentityMatching(selector, fields...) + selector[1] = configkit.SequenceLiteralKey("other") + fields[0] = "changed" + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}} + + underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{ + "team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}}, + }}) + main := configkit.Mapping("main", map[string]any{"collections": map[string]any{ + "team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}}, + }}) + loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}}, + }) + if err != nil { + t.Fatalf("load after mutating constructor inputs: %v", err) + } + prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any) + if len(prompts) != 2 { + t.Fatalf("registered compound identity did not append records: %#v", prompts) + } +} + +func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) { + dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), + configkit.SequenceAnyMappingKey(), + configkit.SequenceLiteralKey("prompts"), + }, "id") + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"), + dynamic, + }} + underlay := configkit.Mapping("underlay", map[string]any{ + "prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}}, + "collections": map[string]any{ + "team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}}, + "cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}}, + }, + }) + main := configkit.Mapping("main", map[string]any{ + "prompts": []any{map[string]any{"namespace": "beta", "id": "same"}}, + "collections": map[string]any{ + "team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}}, + "cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}}, + }, + }) + loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}}, + }) + if err != nil { + t.Fatalf("load mixed exact and dynamic registrations: %v", err) + } + rootRecords := loaded.Config["prompts"].([]any) + if len(rootRecords) != 2 { + t.Fatalf("compound root identities = %#v", rootRecords) + } + collections := loaded.Config["collections"].(map[string]any) + for _, name := range []string{"team", "cloud"} { + prompts := collections[name].(map[string]any)["prompts"].([]any) + if len(prompts) != 2 { + t.Errorf("dynamic prompts for %q = %#v", name, prompts) + } + } +} + +func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) { + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), + configkit.SequenceLiteralKey("*"), + configkit.SequenceLiteralKey("prompts"), + }, "id"), + }} + underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{ + "*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}}, + "team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}}, + }}) + main := configkit.Mapping("main", map[string]any{"collections": map[string]any{ + "*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}}, + "team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}}, + }}) + _, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}}, + }) + loadErr := requireFacadeLoadError(t, err) + if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" { + t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics()) + } + + wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), + configkit.SequenceAnyMappingKey(), + configkit.SequenceLiteralKey("prompts"), + }, "id"), + }} + loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{ + Definition: wildcard, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}}, + }) + if err != nil { + t.Fatalf("wildcard registration did not match mapping keys: %v", err) + } + for name, raw := range loaded.Config["collections"].(map[string]any) { + if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 { + t.Errorf("wildcard result for %q has %d records", name, got) + } + } +} + +func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) { + tests := []struct { + name string + identity configkit.SequenceIdentity + wantPath string + }{ + { + name: "index path", + identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"), + wantPath: "/records/0", + }, + { + name: "empty fields", + identity: configkit.SequenceIdentityAt(configkit.Keys("records")), + wantPath: "/records", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := strings.NewReader("records: []\n") + _, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{ + Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}}, + }) + loadErr := requireFacadeLoadError(t, err) + if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath { + t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics()) + } + if reader.Len() == 0 { + t.Fatal("source was read before registration validation") + } + }) + } +} + +func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) { + selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()} + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityMatching(selector, "id"), + configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"), + }, "slug"), + }} + reader := strings.NewReader("collections: {}\n") + _, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition}) + loadErr := requireFacadeLoadError(t, err) + if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid { + t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics()) + } + if reader.Len() == 0 { + t.Fatal("source was read before overlapping registrations were rejected") + } +} + +func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) { + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("entries"), + configkit.SequenceLiteralKey("child"), + configkit.SequenceLiteralKey("collections"), + configkit.SequenceAnyMappingKey(), + configkit.SequenceLiteralKey("prompts"), + }, "id"), + }} + loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs: +entries: + parent: + collections: + team: + prompts: [{id: inherited, text: base}] + child: + extends: parent + collections: + team: + prompts: [{id: added, text: child}] +`)), configkit.Options[map[string]any]{Definition: definition}) + if err != nil { + t.Fatalf("load with dynamic inherited destination: %v", err) + } + child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any) + prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any) + if len(prompts) != 2 { + t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts) + } +} + +func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) { + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityMatching(configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), + }, "id"), + }} + for _, source := range []string{"first", "second"} { + loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{ + source: []any{map[string]any{"id": "reused"}}, + }}), configkit.Options[map[string]any]{Definition: definition}) + if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 { + t.Fatalf("independent load %q = %#v, %v", source, loaded, err) + } + } +} + +func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError { + t.Helper() + var loadErr *configkit.LoadError + if !errors.As(err, &loadErr) { + t.Fatalf("error = %v, want *LoadError", err) + } + return loadErr +} diff --git a/facade_sequence_test.go b/facade_sequence_test.go index 01a18d5..ae44ade 100644 --- a/facade_sequence_test.go +++ b/facade_sequence_test.go @@ -21,7 +21,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) { definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} } } if registered { - definition.SequenceCompositions = []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}} + definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")} } ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition}) if err != nil { @@ -63,7 +63,7 @@ func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) { "child-helper.yml": {Data: []byte("text: nested helper\n")}, }) result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{ - Imports: true, Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}, + Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}, }) if err != nil { t.Fatal(err) @@ -114,7 +114,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) { mainYAML += "records: [{id: main}]\n" } result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{ - Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}, + Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}}, }) if err != nil { @@ -143,7 +143,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) { "bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte("id: duplicate-field\n")}, }) - definition := configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}} + definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}} for _, test := range []struct { name, file, path string grouped, itemMain bool @@ -200,7 +200,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) { func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) { definition := configkit.Definition[map[string]any]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}}, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")}, } ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{ Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem, @@ -301,7 +301,7 @@ items: result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{ Imports: true, Definition: configkit.Definition[map[string]any]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("items"), IdentityField: "id"}}, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")}, Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { for _, id := range []string{"source-2", "source-3"} { source, ok := resolution.Source(id) @@ -461,15 +461,17 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) { } } -func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) { - registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}} +func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) { + fields := []string{"id"} + path := configkit.Keys("records") + registration := configkit.SequenceIdentityAt(path, fields...) + fields[0] = "changed" definition := configkit.Definition[map[string]any]{ - SequenceCompositions: registrations, + SequenceIdentities: []configkit.SequenceIdentity{registration}, Version: &configkit.VersionRule{ Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { - registrations[0].Path = configkit.Keys("other") - registrations[0].IdentityField = "changed" + path = configkit.Keys("other") return nil }, }, @@ -514,7 +516,7 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) { func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) { definition := configkit.Definition[map[string]any]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}}, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")}, } result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{ Definition: definition, @@ -595,7 +597,7 @@ entries: result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}}, Definition: configkit.Definition[configuration]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}}, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")}, Secret: func(path configkit.Path) bool { return path.String() == "/entries/0/token" || path.String() == "/entries/1/token" }, diff --git a/facade_source_version_mode_test.go b/facade_source_version_mode_test.go index 39b981c..83c0b55 100644 --- a/facade_source_version_mode_test.go +++ b/facade_source_version_mode_test.go @@ -23,9 +23,9 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} { t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) { definition := configkit.Definition[map[string]any]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}, - Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, - Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath }, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}, + Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}, + Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath }, } first := "records: [{id: first, version: first-version}]\n" child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n" @@ -86,8 +86,8 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) { for _, grouped := range []bool{false, true} { for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} { definition := configkit.Definition[map[string]any]{ - SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}, - Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }}, + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}, + Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }}, } first := "records: [{id: first, version: first}]\n" child := "records: [{id: child, version: child}]\n" diff --git a/internal/engine/compose_test.go b/internal/engine/compose_test.go index 41f254a..11438b8 100644 --- a/internal/engine/compose_test.go +++ b/internal/engine/compose_test.go @@ -10,17 +10,20 @@ import ( ) func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) { - policy := sequencePolicy{identityFields: make(map[string]string)} + registrations := make([]sequenceIdentityRegistration, 0, 128) var inputs []*document.Node for source := 0; source < 3; source++ { var text strings.Builder for collection := 0; collection < 128; collection++ { key := fmt.Sprintf("records%d", collection) - policy.identityFields[Keys(key).String()] = "id" + registrations = append(registrations, sequenceIdentityRegistration{ + selector: sequenceIdentitySelector{literalSequenceSelector(key)}, identityFields: []string{"id"}, + }) fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source) } inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String())) } + policy := mustCompileSequenceIdentityPolicy(t, registrations) composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...) if len(diagnostics) != 0 { t.Fatal(diagnostics) @@ -242,7 +245,7 @@ func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n") second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n") firstBefore, secondBefore := document.Clone(first), document.Clone(second) - policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}) composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) if len(diagnostics) != 0 { @@ -287,10 +290,10 @@ escaped/~: name: escaped-second-name `) firstBefore, secondBefore := document.Clone(first), document.Clone(second) - policy := sequencePolicy{identityFields: map[string]string{ - Keys("mounted", "records").String(): "id", - Keys("escaped/~", "7").String(): "id", - }} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("mounted"), literalSequenceSelector("records")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("escaped/~"), literalSequenceSelector("7")}, identityFields: []string{"id"}}, + }) composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) if len(diagnostics) != 0 { @@ -442,7 +445,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, diagnostics := composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/records": "id"}}, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), parseCompositionDocument(t, "source-1", test.body), ) if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path { @@ -570,7 +573,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) } _, diagnostics = composeDocumentsWithCollectionPolicy( - sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower, []sequenceDeclarationSpec{declaration}, parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"), ) @@ -581,7 +584,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) { _, diagnostics := composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/records": "id"}}, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"), parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"), ) @@ -613,7 +616,7 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) { inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second)) } _, diagnostics := composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs..., + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), inputs..., ) if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" { t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics) @@ -631,9 +634,12 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) { } func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) { - policy := sequencePolicy{identityFields: map[string]string{ - "/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id", - }} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("absent")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("not-a-sequence")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("catalog")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("records")}, identityFields: []string{"id"}}, + }) composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", ` not-a-sequence: null catalog: @@ -647,7 +653,7 @@ catalog: } composed, diagnostics = composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/records": "id"}}, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), parseCompositionDocument(t, "source-1", "records: []\n"), parseCompositionDocument(t, "source-2", "records: []\n"), ) @@ -664,7 +670,7 @@ catalog: func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) { _, diagnostics := composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/registered": "id"}}, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("registered")}, identityFields: []string{"id"}}}), parseCompositionDocument(t, "source-1", "unregistered: [one]\n"), parseCompositionDocument(t, "source-2", "unregistered: [two]\n"), ) @@ -684,7 +690,7 @@ func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testin for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, diagnostics := composeDocumentsWithPolicy( - sequencePolicy{identityFields: map[string]string{"/records": "id"}}, + mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), parseCompositionDocument(t, "source-1", test.first), parseCompositionDocument(t, "source-2", test.second), ) diff --git a/internal/engine/destination_integration_test.go b/internal/engine/destination_integration_test.go index b56a5f6..91950ff 100644 --- a/internal/engine/destination_integration_test.go +++ b/internal/engine/destination_integration_test.go @@ -197,8 +197,8 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) { t.Parallel() definition := Definition[map[string]any]{ - Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}, - sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}}, + Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}, + sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}}, } item := func(label string, value map[string]any) Input { return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem} diff --git a/internal/engine/history_sequence_test.go b/internal/engine/history_sequence_test.go index 1ad552a..dc41ca6 100644 --- a/internal/engine/history_sequence_test.go +++ b/internal/engine/history_sequence_test.go @@ -10,7 +10,10 @@ import ( ) func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) { - policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}}, + }) first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n") second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n") composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) @@ -76,7 +79,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) { } func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) { - policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("independent")}, identityFields: []string{"id"}}, + }) first, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"), parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"), @@ -148,7 +154,7 @@ func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) { } func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) { - policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}) composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"), parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"), @@ -193,7 +199,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T } func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) { - policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}} + policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}) composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"), parseCompositionDocument(t, "second", "records: [{id: second, token: PRIVATE-RECORD-CANARY}]"), diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index a54709e..9f2aa73 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -115,7 +115,7 @@ tasks: [{id: z-parent}] input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{ Imports: true, - sequencePolicy: sequencePolicy{identityFields: map[string]string{"/tasks": "id"}}, + sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}), }) if len(diagnostics) != 0 { t.Fatalf("assemble imports: %#v", diagnostics) @@ -679,7 +679,7 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) t.Fatalf("assemble item import: %#v", diagnostics) } authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n") - composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document) + composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document) if len(diagnostics) != 0 { t.Fatalf("compose authored and imported lists: %#v", diagnostics) } @@ -1009,7 +1009,7 @@ func TestItemContributionsUseDependencyReadySchedules(t *testing.T) { type config struct { Items []item `yaml:"items"` } - definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}} + definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}} directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", `configkit: imports: diff --git a/internal/engine/item_policy_test.go b/internal/engine/item_policy_test.go index b8831bf..bb9abb6 100644 --- a/internal/engine/item_policy_test.go +++ b/internal/engine/item_policy_test.go @@ -197,7 +197,7 @@ func TestNestedItemPolicyProjectionUsesStableOutputIndexes(t *testing.T) { func TestOverlaySequenceTransfersIncomingItemPolicyOwnership(t *testing.T) { path := Keys("records") - registered := mustCompileSequencePolicy(t, []sequenceRegistration{{path: path, identityField: "id"}}) + registered := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}) makePolicies := func(childKey string) collectionPolicyScope { local := collectionPolicyScope{} local.set(Keys("steps"), collectionPolicy{ diff --git a/internal/engine/load.go b/internal/engine/load.go index e28555c..f113d14 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -17,7 +17,7 @@ type Definition[T any] struct { Version *VersionRule Secret func(Path) bool - sequenceRegistrations []sequenceRegistration + sequenceIdentityRegistrations []sequenceIdentityRegistration } // Options contains the application definition and per-load input selections. @@ -146,13 +146,13 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) ( func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) { options, diagnostics := selectOptions(source, supplied) - policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations) + policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations) diagnostics = append(diagnostics, policyDiagnostics...) if len(diagnostics) != 0 { return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError() } definition := options.Definition - definition.sequenceRegistrations = nil + definition.sequenceIdentityRegistrations = nil request := loadRequest[T]{ main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode}, definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay, diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 9c7953d..858482b 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -52,10 +52,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi var diagnostics []Diagnostic request.importDefaults = snapshotImportDefaults(request.importDefaults) diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...) - policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations) + policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations) request.sequencePolicy = policy diagnostics = append(diagnostics, policyDiagnostics...) - request.definition.sequenceRegistrations = nil + request.definition.sequenceIdentityRegistrations = nil mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{ Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode, diff --git a/internal/engine/load_request_test.go b/internal/engine/load_request_test.go index 6e2f1eb..b8ad56d 100644 --- a/internal/engine/load_request_test.go +++ b/internal/engine/load_request_test.go @@ -46,7 +46,7 @@ items: [{id: b}] {source: File(zRoot), imports: true}, {source: File(bRoot), imports: true}, }}}, - definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}, + definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, } result, err := testGroupedLoad(request) if err != nil { @@ -132,7 +132,7 @@ func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testi {source: secondNamespace.File("root.yml")}, {source: firstNamespace.File("later.yml")}, }}}, - definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}, + definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, } result, err := testGroupedLoad(request) if err != nil { @@ -163,7 +163,7 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) { {source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem}, }}}, - definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}, + definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, } _, err := testGroupedLoad(request) loadError, ok := err.(*LoadError) @@ -207,7 +207,7 @@ func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) { {source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem}, {source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem}, }}}, - definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}, + definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}, } _, err := testGroupedLoad(request) loadError, ok := err.(*LoadError) @@ -403,11 +403,11 @@ func TestLayeredCollectionPolicyFlowsBetweenTiers(t *testing.T) { filesystem := NewFilesystem("overlay", fstest.MapFS{ "overlays/testing.yml": &fstest.MapFile{Data: []byte("configkit:\n sequences:\n - destination: [items]\n key: id\n mode: append\nitems: [{id: overlay}]\n")}, }) - definition := Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}} + definition := Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}} request = loadRequest[map[string]any]{ main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))}, underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}}, - definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations), + definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations), overlay: "testing", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}, } result, err = testGroupedLoad(request) diff --git a/internal/engine/sequence_registration.go b/internal/engine/sequence_registration.go index ba88377..513c466 100644 --- a/internal/engine/sequence_registration.go +++ b/internal/engine/sequence_registration.go @@ -1,10 +1,5 @@ package engine -type sequenceRegistration struct { - path Path - identityField string -} - type sequenceSelectorSegmentKind uint8 const ( @@ -22,6 +17,24 @@ type sequenceIdentitySelector []sequenceSelectorSegment type sequenceIdentityRegistration struct { selector sequenceIdentitySelector identityFields []string + invalidPath *Path +} + +// SequenceIdentitySelectorSegment carries one root-owned selector segment into +// an engine definition. This transport type remains behind the internal package +// boundary. +type SequenceIdentitySelectorSegment struct { + Key string + AnyMappingKey bool +} + +// SequenceIdentityRegistrationInput carries detached root-owned registration +// data into an engine definition without exposing its compiled policy. A +// non-nil ExactPath selects exact-path registration; otherwise Selector is used. +type SequenceIdentityRegistrationInput struct { + ExactPath *Path + Selector []SequenceIdentitySelectorSegment + IdentityFields []string } type sequenceSelectorTrie struct { @@ -32,62 +45,59 @@ type sequenceSelectorTrie struct { } type sequencePolicy struct { - // identityFields preserves the old exact-path view for existing internal - // callers while selector matching is introduced behind the same policy. - identityFields map[string]string - selectorTrie *sequenceSelectorTrie + selectorTrie *sequenceSelectorTrie } -// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a -// detached engine definition without exposing the registration representation. -func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] { - if paths == nil { - definition.sequenceRegistrations = nil +// DefinitionWithSequenceIdentityRegistrations attaches facade-owned +// registrations to a detached engine definition without exposing compiled +// engine policy. +func DefinitionWithSequenceIdentityRegistrations[T any](definition Definition[T], registrations []SequenceIdentityRegistrationInput) Definition[T] { + if registrations == nil { + definition.sequenceIdentityRegistrations = nil return definition } - definition.sequenceRegistrations = make([]sequenceRegistration, len(paths)) - for index := range paths { - definition.sequenceRegistrations[index] = sequenceRegistration{ - path: paths[index].clone(), identityField: identityFields[index], + definition.sequenceIdentityRegistrations = make([]sequenceIdentityRegistration, len(registrations)) + for index, registration := range registrations { + compiled := sequenceIdentityRegistration{identityFields: append([]string(nil), registration.IdentityFields...)} + if registration.ExactPath != nil { + path, err := normalizeDestination(registration.ExactPath.clone()) + if err != nil { + invalid := registration.ExactPath.clone() + compiled.invalidPath = &invalid + } else { + compiled.selector = make(sequenceIdentitySelector, len(path.segments)) + for segmentIndex, segment := range path.segments { + compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key} + } + } + } else { + compiled.selector = make(sequenceIdentitySelector, len(registration.Selector)) + for segmentIndex, segment := range registration.Selector { + kind := sequenceSelectorLiteral + if segment.AnyMappingKey { + kind = sequenceSelectorAnyMappingKey + } + compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: kind, key: segment.Key} + } } + definition.sequenceIdentityRegistrations[index] = compiled } return definition } -func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) { - common := make([]sequenceIdentityRegistration, 0, len(registrations)) - var diagnostics []Diagnostic - legacyPaths := make(map[string]string, len(registrations)) - for _, registration := range registrations { - path, err := normalizeDestination(registration.path) - if err != nil { - diagnostics = append(diagnostics, Diagnostic{ - Code: CodeOptionsInvalid, Severity: SeverityError, - Path: registration.path.clone(), Message: "sequence registration path must contain only non-reserved mapping keys", - }) - continue - } - selector := make(sequenceIdentitySelector, len(path.segments)) - for index, segment := range path.segments { - selector[index] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key} - } - common = append(common, sequenceIdentityRegistration{ - selector: selector, identityFields: []string{registration.identityField}, - }) - legacyPaths[path.String()] = registration.identityField - } - policy, compiledDiagnostics := compileSequenceIdentityPolicy(common) - policy.identityFields = legacyPaths - diagnostics = append(diagnostics, compiledDiagnostics...) - return policy, canonicalDiagnostics(diagnostics) -} - func compileSequenceIdentityPolicy(registrations []sequenceIdentityRegistration) (sequencePolicy, []Diagnostic) { policy := sequencePolicy{} compiled := make([]sequenceIdentityRegistration, 0, len(registrations)) var diagnostics []Diagnostic for _, registration := range registrations { selector := cloneSequenceIdentitySelector(registration.selector) + if registration.invalidPath != nil { + diagnostics = append(diagnostics, Diagnostic{ + Code: CodeOptionsInvalid, Severity: SeverityError, Path: registration.invalidPath.clone(), + Message: "sequence registration path must contain only non-reserved mapping keys", + }) + continue + } fields, err := normalizeSequenceIdentityFields(registration.identityFields) if err != nil { diagnostics = append(diagnostics, Diagnostic{ @@ -172,11 +182,7 @@ func (p sequencePolicy) identityFieldsAt(path Path) ([]string, bool) { return nil, false } if p.selectorTrie == nil { - field, ok := p.identityFields[path.String()] - if !ok { - return nil, false - } - return []string{field}, true + return nil, false } current := []*sequenceSelectorTrie{p.selectorTrie} for _, segment := range path.segments { diff --git a/internal/engine/sequence_registration_test.go b/internal/engine/sequence_registration_test.go index f80e4b7..04b345a 100644 --- a/internal/engine/sequence_registration_test.go +++ b/internal/engine/sequence_registration_test.go @@ -7,54 +7,44 @@ import ( "testing/fstest" ) -func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) { - parsed, err := ParsePath("/items/0/a~1b") - if err != nil { - t.Fatal(err) - } - +func TestCompileSequenceIdentityPolicyNormalizesRegistrations(t *testing.T) { tests := []struct { name string - registrations []sequenceRegistration + registrations []sequenceIdentityRegistration want map[string]string wantError bool }{ { name: "identical registrations coalesce", - registrations: []sequenceRegistration{ - {path: Keys("items"), identityField: "id"}, - {path: Keys("items"), identityField: "id"}, + registrations: []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}, }, want: map[string]string{"/items": "id"}, }, { name: "different identity fields conflict", - registrations: []sequenceRegistration{ - {path: Keys("items"), identityField: "id"}, - {path: Keys("items"), identityField: "name"}, + registrations: []sequenceIdentityRegistration{ + {selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"name"}}, }, wantError: true, }, { name: "empty identity field is literal", - registrations: []sequenceRegistration{{path: Keys("items"), identityField: ""}}, + registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{""}}}, want: map[string]string{"/items": ""}, }, { name: "numeric and escaped tokens remain literal keys", - registrations: []sequenceRegistration{{path: parsed, identityField: "id"}}, + registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items"), literalSequenceSelector("0"), literalSequenceSelector("a/b")}, identityFields: []string{"id"}}}, want: map[string]string{"/items/0/a~1b": "id"}, }, - { - name: "explicit index is invalid", - registrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}, - wantError: true, - }, { name: "unused and root registrations are accepted", - registrations: []sequenceRegistration{ - {path: Path{}, identityField: "id"}, - {path: Keys("unused"), identityField: "name"}, + registrations: []sequenceIdentityRegistration{ + {selector: nil, identityFields: []string{"id"}}, + {selector: sequenceIdentitySelector{literalSequenceSelector("unused")}, identityFields: []string{"name"}}, }, want: map[string]string{"": "id", "/unused": "name"}, }, @@ -63,7 +53,7 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - policy, diagnostics := compileSequencePolicy(test.registrations) + policy, diagnostics := compileSequenceIdentityPolicy(test.registrations) if (len(diagnostics) != 0) != test.wantError { t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError) } @@ -73,12 +63,14 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) { } return } - if len(policy.identityFields) != len(test.want) { - t.Fatalf("compiled fields = %#v, want %#v", policy.identityFields, test.want) - } for path, field := range test.want { - if got := policy.identityFields[path]; got != field { - t.Errorf("identity field at %q = %q, want %q", path, got, field) + parsed, err := ParsePath(path) + if err != nil { + t.Fatal(err) + } + gotFields, ok := policy.identityFieldsAt(parsed) + if !ok || len(gotFields) != 1 || gotFields[0] != field { + t.Errorf("identity fields at %q = %#v, want [%q]", path, gotFields, field) } } }) @@ -247,7 +239,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) { ordinaryReader := strings.NewReader("value: ordinary\n") _, err := Load[config](Reader("ordinary.yml", ordinaryReader), SecretBinding{}, Options[config]{ - Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}}, + Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}), }) requireSequenceRegistrationOptionError(t, err) if ordinaryReader.Len() == 0 { @@ -257,7 +249,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) { mainReader := strings.NewReader("value: main\n") underlayReader := strings.NewReader("value: underlay\n") _, err = LoadLayered[config](Input{Source: Reader("main.yml", mainReader)}, SecretBinding{}, LayeredOptions[config]{ - Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}}, + Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}), Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}}, }) requireSequenceRegistrationOptionError(t, err) @@ -266,6 +258,8 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) { } } +func pathPointer(path Path) *Path { return &path } + func requireSequenceRegistrationOptionError(t *testing.T, err error) { t.Helper() if err == nil { @@ -289,7 +283,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { type config struct { Items []item `yaml:"items"` } - definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}} + definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}} registered := Options[config]{Definition: definition} loadCode := func(t *testing.T, err error) Code { t.Helper() @@ -327,7 +321,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { {source: Bytes("first", []byte("items: [{id: lower}]\n"))}, {source: Bytes("second", []byte("items: [{id: other}]\n"))}, }}}, - definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations), + definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations), } result, err := testGroupedLoad(request) if err != nil || len(result.Config.Items) != 3 || result.Config.Items[0].ID != "lower" || result.Config.Items[1].ID != "other" || result.Config.Items[2].ID != "shared" { @@ -352,7 +346,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { request := loadRequest[config]{ main: loadInput{source: Bytes("main", []byte("items: []\n"))}, underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}}, - definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations), + definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations), } result, err = testGroupedLoad(request) if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "lower" { @@ -378,7 +372,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { request := loadRequest[config]{ main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))}, underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\n"))}}}}, - definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations), + definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations), } if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict { t.Fatalf("hidden lower-tier error = %s", got) @@ -447,9 +441,9 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) { }) } -func mustCompileSequencePolicy(t *testing.T, registrations []sequenceRegistration) sequencePolicy { +func mustCompileSequenceIdentityPolicy(t *testing.T, registrations []sequenceIdentityRegistration) sequencePolicy { t.Helper() - policy, diagnostics := compileSequencePolicy(registrations) + policy, diagnostics := compileSequenceIdentityPolicy(registrations) if len(diagnostics) != 0 { t.Fatal(diagnostics) } diff --git a/lifecycle.go b/lifecycle.go index c7d3578..5bcfbeb 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -15,15 +15,11 @@ type Definition[T any] struct { Validate func(*T, *Validation) Version *VersionRule Secret func(Path) bool - // SequenceCompositions registers mapping paths whose sequence contributions - // strictly append distinct records by the selected literal identity field. - // Invalid or conflicting registrations fail option validation. Repeated input - // identities fail strict composition after source and helper processing. The - // field also supplies identity for parent lookup when surviving records at - // that path use record-inheritance directives. - // Registration paths are copied when a load begins; definitions are read-only - // after being shared. - SequenceCompositions []SequenceComposition + // SequenceIdentities declares the scalar fields used to combine records and + // resolve record inheritance at selected sequence destinations. Registration + // errors are reported before source acquisition. These constraints do not yet + // validate identities introduced by defaults, CLI overrides, or callbacks. + SequenceIdentities []SequenceIdentity } // PlacementMode selects how an input mapping is placed at its Destination. @@ -38,20 +34,6 @@ const ( PlacementModeSequenceItem ) -// SequenceComposition registers a sequence path for strict identity-based -// composition. Path is copied at load start; IdentityField is one literal key, -// including the empty string. Invalid or conflicting registrations fail option -// validation. -type SequenceComposition struct { - // Path selects a literal mapping path; registration paths are copied at load - // start. Invalid paths fail option validation; the root path is harmless. - Path Path - // IdentityField is one literal mapping key and may be empty. Records must - // contain a string at this key; malformed records fail strict composition. - // The field also identifies parents for record inheritance at this path. - IdentityField string -} - // SourceVersionMode controls schema-version declarations across one complete // load. The zero value preserves the existing main-only effective-path check // and its protected-path behavior. Enabled modes check participating documents @@ -360,14 +342,12 @@ func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemCon func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] { result := engine.Definition[T]{Defaults: definition.Defaults} - if definition.SequenceCompositions != nil { - paths := make([]engine.Path, len(definition.SequenceCompositions)) - identityFields := make([]string, len(definition.SequenceCompositions)) - for index, registration := range definition.SequenceCompositions { - paths[index] = toEnginePath(registration.Path.clone()) - identityFields[index] = registration.IdentityField + if definition.SequenceIdentities != nil { + registrations := make([]engine.SequenceIdentityRegistrationInput, len(definition.SequenceIdentities)) + for index, identity := range definition.SequenceIdentities { + registrations[index] = toEngineSequenceIdentity(identity) } - result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields) + result = engine.DefinitionWithSequenceIdentityRegistrations(result, registrations) } if definition.Resolve != nil { result.Resolve = func(configuration *T, resolution *engine.Resolution) error { diff --git a/sequence_identity.go b/sequence_identity.go new file mode 100644 index 0000000..49ab258 --- /dev/null +++ b/sequence_identity.go @@ -0,0 +1,74 @@ +package configkit + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" + +// SequenceSelector identifies a sequence destination through literal mapping +// keys and arbitrary mapping-key segments. A zero selector identifies the root. +type SequenceSelector []SequenceSelectorSegment + +// SequenceSelectorSegment is one literal mapping key or wildcard mapping key. +// Its zero value is the literal empty key. +type SequenceSelectorSegment struct { + key string + anyMappingKey bool +} + +// SequenceLiteralKey selects a literal mapping key, including an empty key or +// the literal string "*". +func SequenceLiteralKey(key string) SequenceSelectorSegment { + return SequenceSelectorSegment{key: key} +} + +// SequenceAnyMappingKey selects any single mapping key. +func SequenceAnyMappingKey() SequenceSelectorSegment { + return SequenceSelectorSegment{anyMappingKey: true} +} + +// SequenceIdentity records the identity fields used for strict sequence +// composition and identified-record parent lookup. It does not add final-result +// identity validation; that check remains the application's responsibility. +type SequenceIdentity struct { + exactPath bool + path Path + selector SequenceSelector + identityFields []string +} + +// SequenceIdentityAt registers identity fields at an exact mapping path. The +// path and fields are copied; invalid paths and empty field sets are reported +// during load validation. +func SequenceIdentityAt(path Path, fields ...string) SequenceIdentity { + return SequenceIdentity{ + exactPath: true, + path: path.clone(), + identityFields: append([]string(nil), fields...), + } +} + +// SequenceIdentityMatching registers identity fields at destinations matched by +// selector. Literal and wildcard selector segments are copied. Empty field sets +// are reported during load validation. +func SequenceIdentityMatching(selector SequenceSelector, fields ...string) SequenceIdentity { + return SequenceIdentity{ + selector: append(SequenceSelector(nil), selector...), + identityFields: append([]string(nil), fields...), + } +} + +func toEngineSequenceIdentity(identity SequenceIdentity) engine.SequenceIdentityRegistrationInput { + registration := engine.SequenceIdentityRegistrationInput{ + IdentityFields: append([]string(nil), identity.identityFields...), + } + if identity.exactPath { + path := toEnginePath(identity.path.clone()) + registration.ExactPath = &path + return registration + } + registration.Selector = make([]engine.SequenceIdentitySelectorSegment, len(identity.selector)) + for index, segment := range identity.selector { + registration.Selector[index] = engine.SequenceIdentitySelectorSegment{ + Key: segment.key, AnyMappingKey: segment.anyMappingKey, + } + } + return registration +}