Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
12 KiB
Пер-записевый выбор в 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для toneupdate/delete— чекбокс в заголовке секции (select-all: отмечен если все выбраны, indeterminate если часть) и чекбокс в каждойRecordRow(отмечен поselected.has(record.key)). Для tonereadonly— без чекбоксов (не выбираемо). -
Используй существующий
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+ желаемый CNAMEmail) отметить prune Amailи update CNAMEmail, Apply → удаление проходит раньше, CNAME создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.