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

220 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Пер-записевый выбор в 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<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`.
```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 создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.