docs: plan for per-record apply selection + deletes-first order
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -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<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 создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.
|
||||||
Reference in New Issue
Block a user