fix(diff): merge template records sharing name+type into one RRset

Two template rows at the same name and type (e.g. two MX) produced two
diffs with one key; each PATCH replaced the whole provider RRset, so only
the last value survived. Merge values by key before diffing.

docs: add lint/single-test commands and compose files to CLAUDE.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vasyanskandClaude Sonnet 5.5 committed 2026-11-02 17:26:01 +07:00
1 parent 5e12c4729b
commit c5f1e64873
3 files changed
+63 -2

No files matched your search

+4 -2
View File
@@ -15,11 +15,13 @@ make build-all # web + build
make docker-up # docker compose: app + postgres (собирает образ) make docker-up # docker compose: app + postgres (собирает образ)
cd web && npm run test -- --run # фронт-тесты (Vitest) cd web && npm run test -- --run # фронт-тесты (Vitest)
cd web && npx vitest run src/path/File.test.tsx # один фронт-тест
cd web && npm run lint # oxlint
cd web && npx tsc --noEmit # проверка типов cd web && npx tsc --noEmit # проверка типов
cd web && npm run build # прод-сборка фронта cd web && npm run build # прод-сборка фронта (tsc -b && vite build)
``` ```
Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте. Конфигурация только через env: `DNS_AR_DB_DSN`, `DNS_AR_ENC_KEY` (base64, декодит в **ровно 32 байта**; `openssl rand -base64 32`), `DNS_AR_LISTEN` (default `:8080`). Миграции (goose) гоняются приложением на старте. Compose-файлы: `docker-compose.yml` (локально), `docker-compose-prod.yml` (прод). `AGENTS.md` — копия этого файла для Codex (untracked): при правках синхронизировать или не коммитить.
## Критичные подводные камни ## Критичные подводные камни
+38
View File
@@ -90,6 +90,7 @@ func (c Changeset) Prunes() []RecordDiff {
// Diff compares a template against the actual zone records. // Diff compares a template against the actual zone records.
// Records present in the zone but absent from the template yield Delete. // Records present in the zone but absent from the template yield Delete.
func Diff(template, actual []model.Record) Changeset { func Diff(template, actual []model.Record) Changeset {
template = mergeByKey(template)
current := index(actual) current := index(actual)
seen := make(map[string]bool, len(template)) seen := make(map[string]bool, len(template))
var diffs []RecordDiff var diffs []RecordDiff
@@ -120,6 +121,43 @@ func Diff(template, actual []model.Record) Changeset {
return Changeset{Diffs: diffs} return Changeset{Diffs: diffs}
} }
// mergeByKey folds template records sharing a Key() into one RRset whose
// Values are the union (deduplicated by normalized value) in first-seen order.
// A template may list several records at the same name+type (two MX rows);
// the provider models them as ONE RRset, so applying them separately would
// make each replace the previous. TTL of the first record wins. Order of
// first occurrences is preserved.
func mergeByKey(recs []model.Record) []model.Record {
out := make([]model.Record, 0, len(recs))
pos := make(map[string]int, len(recs))
for _, r := range recs {
k := r.Key()
i, ok := pos[k]
if !ok {
r.Values = append([]string(nil), r.Values...)
pos[k] = len(out)
out = append(out, r)
continue
}
have := make(map[string]bool, len(out[i].Values))
for _, v := range out[i].Values {
have[normalizeValueOf(out[i].Type, v)] = true
}
for _, v := range r.Values {
nv := normalizeValueOf(r.Type, v)
if !have[nv] {
have[nv] = true
out[i].Values = append(out[i].Values, v)
}
}
}
return out
}
func normalizeValueOf(t model.RecordType, v string) string {
return model.Record{Type: t, Values: []string{v}}.NormalizedValues()[0]
}
// index builds a lookup of records by Key(). If two records in recs share // index builds a lookup of records by Key(). If two records in recs share
// the same Key() (e.g. a provider returning a duplicate RRset entry), this // the same Key() (e.g. a provider returning a duplicate RRset entry), this
// is a deliberate, documented choice, not an oversight: the LAST record with // is a deliberate, documented choice, not an oversight: the LAST record with
+21
View File
@@ -301,3 +301,24 @@ func TestMarkCustomNeverMarksReadOnly(t *testing.T) {
t.Fatalf("read-only diffs must never become custom, got %+v", got) t.Fatalf("read-only diffs must never become custom, got %+v", got)
} }
} }
// Two template rows at the same name+type are one RRset: values are merged.
func TestDiffMergesTemplateRecordsWithSameKey(t *testing.T) {
tpl := []model.Record{
{Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"0 pmg2-mail.example.com."}},
{Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com."}},
{Type: model.MX, Name: "Example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com"}}, // dup value
}
cs := Diff(tpl, nil)
if len(cs.Diffs) != 1 {
t.Fatalf("want 1 diff, got %d", len(cs.Diffs))
}
d := cs.Diffs[0]
if d.Kind != Add || len(d.Desired.Values) != 2 {
t.Fatalf("want Add with 2 values, got %v %v", d.Kind, d.Desired.Values)
}
actual := []model.Record{{Type: model.MX, Name: "example.com.", TTL: 600, Values: []string{"10 pmg1-mail.example.com.", "0 pmg2-mail.example.com."}}}
if got := Diff(tpl, actual).Diffs[0].Kind; got != InSync {
t.Fatalf("want InSync, got %v", got)
}
}