Files
dns-autoresolver/docs/superpowers/plans/2026-07-05-selective-apply-order.md

12 KiB
Raw Permalink Blame History

Пер-записевый выбор в Apply + порядок «удаления раньше обновлений» Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use - [ ].

Goal: (1) Дать выбирать чекбоксами конкретные записи для применения (updates и prunes), а не «всё или ничего». (2) Применять удаления (prunes) ДО обновлений (updates), иначе провайдер отвергает конфликтующие изменения (нельзя создать CNAME на имени, где ещё живёт A-запись).

Architecture: ApplyRequest из двух булевых превращается в два списка выбранных ключей записей. Каждая запись в diff-ответе получает стабильный key (нормализованный ТИП имя.), которым оперируют чекбоксы фронта и по которому бэк фильтрует. service.Apply собирает выбранные prunes, затем выбранные updates — провайдер применяет удаления первыми.

Tech Stack: Go (diff/service/api), React + Vite.

Global Constraints

  • Ключ записи: RecordDiff.Key() — нормализованный ТИП имя. (через model.Record.Key() по Type+Name). Фронт НЕ конструирует ключ сам — берёт key из ответа diff и возвращает его в Apply.
  • Порядок применения — ИНВАРИАНТ: выбранные prunes (Delete) добавляются в набор ДО выбранных updates (Add/Update). Не опция.
  • Только actionable-записи выбираемы; read-only (NS/SOA) чекбоксов не имеют и никогда не применяются.
  • Multi-tenancy/IDOR: Apply уже скоуплен по projectID (resolve по pid) — не регрессировать.
  • Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку internal/web/dist/*.

Task 1: Backend — ключи записей + выборочный Apply с порядком deletes-first

Files:

  • Modify: internal/diff/diff.go (RecordDiff.Key), internal/api/dto.go (recordView.Key + applyRequest), internal/service/service.go (ApplyRequest + Apply), internal/api/handlers.go (handleApply маппинг)
  • Test: internal/diff/diff_test.go, internal/service/service_test.go, internal/api/*_test.go

Interfaces:

  • Produces: diff.RecordDiff.Key() string; recordView.Key; service.ApplyRequest{Updates []string, Prunes []string}.

  • Consumes: model.Record.Key(); Changeset.Updates()/Prunes().

  • Step 1: Тест RecordDiff.Key

internal/diff/diff_test.go:

func TestRecordDiffKeyNormalizes(t *testing.T) {
	d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
	if got := d.Key(); got != "A mail.example.com." {
		t.Fatalf("key: %q", got)
	}
}

Run: go test ./internal/diff/... -run RecordDiffKey — Ожидание FAIL.

  • Step 2: RecordDiff.Key

internal/diff/diff.go:

// Key is the stable identifier of the RRset this diff targets, normalised the
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
// Actual) because Type/Name are always populated.
func (d RecordDiff) Key() string {
	return model.Record{Type: d.Type, Name: d.Name}.Key()
}

Run: go test ./internal/diff/... -run RecordDiffKey — Ожидание PASS.

  • Step 3: recordView.Key + applyRequest

internal/api/dto.go:

type recordView struct {
	Key      string   `json:"key"`
	Kind     string   `json:"kind"`
	Type     string   `json:"type"`
	Name     string   `json:"name"`
	Desired  []string `json:"desired,omitempty"`
	Actual   []string `json:"actual,omitempty"`
	ReadOnly bool     `json:"readOnly"`
}

toRecordView — установить Key: d.Key() (первым полем).

type applyRequest struct {
	Updates []string `json:"updates"`
	Prunes  []string `json:"prunes"`
}

(убрать старые ApplyUpdates/ApplyPrunes bool.)

  • Step 4: service.ApplyRequest + deletes-first selective

internal/service/service.go:

type ApplyRequest struct {
	Updates []string // record keys (RecordDiff.Key) to add/update
	Prunes  []string // record keys to delete
}

Apply:

func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
	p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
	if err != nil {
		return diff.Changeset{}, err
	}
	selPrunes := toSet(req.Prunes)
	selUpdates := toSet(req.Updates)
	var toApply []diff.RecordDiff
	// Deletes first: the provider rejects an Add/Update whose name still has a
	// conflicting record (e.g. a CNAME cannot be created while an A on the same
	// name exists). Pruning the old records before applying updates avoids that.
	for _, d := range cs.Prunes() {
		if selPrunes[d.Key()] {
			toApply = append(toApply, d)
		}
	}
	for _, d := range cs.Updates() {
		if selUpdates[d.Key()] {
			toApply = append(toApply, d)
		}
	}
	applied := diff.Changeset{Diffs: toApply}
	if len(toApply) > 0 {
		if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
			return diff.Changeset{}, err
		}
	}
	return applied, nil
}

func toSet(keys []string) map[string]bool {
	m := make(map[string]bool, len(keys))
	for _, k := range keys {
		m[k] = true
	}
	return m
}

(ApplyChanges итерирует cs.Diffs в порядке слайса — сверено; порядок toApply сохраняется, prunes применяются первыми.)

  • Step 5: handleApply маппинг

internal/api/handlers.go handleApply:

cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
	Updates: req.Updates, Prunes: req.Prunes,
})

(пустое тело → пустые списки → ничего не применяется; сохранить существующую обработку EOF/битого JSON.)

  • Step 6: Тесты

  • service_test.go: changeset с 1 update + 1 prune; Apply{Prunes:[pruneKey]} → применён только prune; Apply{Updates:[updKey]} → только update; Apply{Updates:[updKey], Prunes:[pruneKey]}порядок toApply: prune ПЕРВЫМ, update вторым (проверь через фейковый провайдер, записывающий порядок полученных Diffs — критический тест конфликта CNAME/A). Невыбранные ключи не применяются.

  • api_test.go: POST /apply с {"prunes":["A gitlocator.com."]} → Svc.Apply получил Prunes с этим ключом, Updates пусто.

  • Step 7: Прогон и коммит

Run: go build ./... && go test ./internal/.... Ожидание PASS.

git add internal/
git commit -m "feat(apply): per-record selection + deletes-before-updates ordering"

Task 2: Frontend — чекбоксы на записях, выборочный Apply

Files:

  • Modify: web/src/api/types.ts, web/src/api/client.ts, web/src/hooks/useApi.ts, web/src/components/DiffView.tsx, web/src/pages/DomainDiffPage.tsx
  • Test: web/src/components/DiffView.test.tsx, web/src/pages/DomainDiffPage.test.tsx

Interfaces:

  • Consumes: recordView.key; POST /apply {updates:[], prunes:[]}.

  • Produces: чекбокс на каждой update/prune записи; выбор → Apply шлёт ключи; deletes-first обеспечен бэком.

  • Step 1: types + client + hooks

types.ts: RecordView += key: string; ApplyRequest{ updates: string[]; prunes: string[] }. client.ts applyDomain(projectId, id, body: ApplyRequest) — тело {updates, prunes} (уже сериализует body). useApi.ts useApplyDomain — тип body обновится; onSuccess как есть (инвалидация check).

  • Step 2: DiffView — чекбоксы + select-all

DiffView.tsx: сделать секции Updates/Prunes выбираемыми. Расширить проп:

export function DiffView({
  changeset,
  selectedUpdates, selectedPrunes,       // Set<string>
  onToggleUpdate, onTogglePrune,          // (key: string) => void
  onToggleAllUpdates, onToggleAllPrunes,  // (checked: boolean) => void
  footerExtra,
}: { ... })
  • В Section для tone update/delete — чекбокс в заголовке секции (select-all: отмечен если все выбраны, indeterminate если часть) и чекбокс в каждой RecordRow (отмечен по selected.has(record.key)). Для tone readonly — без чекбоксов (не выбираемо).

  • Используй существующий Checkbox из @/components/ui/checkbox. Ключ строки — record.key.

  • Сохрани визуальный стиль; чекбокс слева от badge типа, выравнивание аккуратное.

  • Step 3: DomainDiffPage — состояние выбора + Apply

DomainDiffPage.tsx:

  • Состояние: selectedUpdates: Set<string>, selectedPrunes: Set<string>.

  • При загрузке changeset — инициализировать: selectedUpdates = все ключи updates (default on), selectedPrunes = пусто (default off, удаление opt-in). Пересчитывать при смене changeset (useEffect на changeset).

  • Тогглы: add/remove ключ; select-all: заполнить/очистить набор ключей секции.

  • Убрать старый общий applyPrunes чекбокс и pruneWarning, завязанный на него. Вместо — предупреждение, если selectedPrunes.size > 0: «Будет удалено записей: N. Действие необратимо.»

  • Apply: apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] }).

  • Кнопка Apply disabled, если selectedUpdates.size + selectedPrunes.size === 0; текст статуса «Готово к применению» / «Изменений для применения нет».

  • Step 4: Тесты

  • DiffView.test.tsx: чекбоксы рендерятся для update/prune записей, не для read-only; клик по чекбоксу вызывает onToggle с record.key; select-all в заголовке.

  • DomainDiffPage.test.tsx: по умолчанию updates отмечены, prunes сняты; отметка prune → предупреждение о количестве; Apply шлёт выбранные {updates:[...], prunes:[...]}; снятие всех → Apply disabled.

  • Step 5: Прогон и коммит

Run: cd web && npm run test -- --run && npx tsc --noEmit && npm run build.

cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "feat(web): per-record apply checkboxes with select-all; prune opt-in"

Итоговая проверка

  • go build ./... && go test ./... — PASS.
  • cd web && npm run test -- --run && npm run build — PASS.
  • Ручная: на домене с конфликтом (A mail + желаемый CNAME mail) отметить prune A mail и update CNAME mail, Apply → удаление проходит раньше, CNAME создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.