diff --git a/docs/superpowers/plans/2026-07-05-selective-apply-order.md b/docs/superpowers/plans/2026-07-05-selective-apply-order.md new file mode 100644 index 0000000..58de106 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-selective-apply-order.md @@ -0,0 +1,219 @@ +# Пер-записевый выбор в 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 создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.