# Пер-записевый выбор в 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`: ```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`: ```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`: ```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()` (первым полем). ```go 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`: ```go type ApplyRequest struct { Updates []string // record keys (RecordDiff.Key) to add/update Prunes []string // record keys to delete } ``` `Apply`: ```go 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`: ```go 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. ```bash 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 выбираемыми. Расширить проп: ```tsx export function DiffView({ changeset, selectedUpdates, selectedPrunes, // Set 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`, `selectedPrunes: Set`. - При загрузке 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`. ```bash 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 создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.