Ten TDD tasks: mark support in the diff engine, the domain_custom_records
table and its store wrappers, wiring marks through LoadDomain into
resolve, the mark/unmark endpoints, the frontend client and Customs
section, import-as-sync with the empty-zone-list guard, and the
{created, removed} import contract on both sides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
58 KiB
Custom-записи и очистка доменов удалённых зон — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Оператор может пометить запись, живущую вне шаблона, как «custom» — она уходит из PRUNES в отдельную секцию, не даёт drift и не применяется; а переимпорт зон удаляет домены, чьих зон больше нет у провайдера.
Architecture: Пометки лежат в таблице domain_custom_records, привязанной к домену. store.LoadDomain кладёт их ключи в service.DomainRef, service.resolve после diff.Diff вызывает Changeset.MarkCustom — одна точка пометки, по аналогии с tmpl.Materialize. Пометка действует только на диффы Kind == Delete: шаблон побеждает. Очистка orphan-доменов встраивается в существующую транзакцию store.ImportDomains.
Tech Stack: Go 1.x (chi, pgx/sqlc, goose, testcontainers-go), React 19 + Vite + TypeScript + TanStack Query, Vitest + RTL.
Spec: docs/superpowers/specs/2026-08-19-custom-records-and-orphan-cleanup-design.md
Global Constraints
- sqlc в среде не установлен. Файлы
internal/store/db/*.sql.goпишутся руками и держатся синхронно сinternal/store/queries/*.sql. Порядок колонок в SQL-строке, в*Row-структуре и вrow.Scan(...)обязан совпадать 1:1. - Multi-tenancy. Каждый store-метод, читающий или пишущий ресурс, принимает
projectIDи фильтрует по нему. Домен грузится только парой(id, projectID). - Планировщик read-only.
internal/schedulerтолько Check + notify, никогда Apply. - Порядок apply: deletes перед updates. Не меняется этим планом.
internal/web/dist/— go:embed target.npm run buildперезаписываетindex.html; перед коммитом всегдаgit checkout internal/web/dist/index.html.- Ошибки провайдера наружу:
service.ErrProviderUnavailable→ 502 с текстом провайдера; внутренние ошибки → genericinternal error(500). - Тесты
internal/store/...требуют запущенного Docker (testcontainers-go). - Ветка работы:
feat/custom-records, merge вmainчерез--no-ffв конце.
Task 1: Пометка Custom в движке диффа
Files:
- Modify:
internal/diff/diff.go - Test:
internal/diff/diff_test.go
Interfaces:
-
Consumes: ничего (первая задача).
-
Produces:
RecordDiff.Custom bool;func (c *Changeset) MarkCustom(keys []string);func (c Changeset) Customs() []RecordDiff.Actionable(),Updates(),Prunes()начинают пропускатьCustom. -
Step 1: Написать падающие тесты
Добавить в конец internal/diff/diff_test.go:
func TestMarkCustomOnlyMarksDeletes(t *testing.T) {
template := []model.Record{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
}
actual := []model.Record{
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
cs := diff.Diff(template, actual)
// Ключ записи, которую шаблон описывает (Update), и ключ лишней записи (Delete).
cs.MarkCustom([]string{"A www.example.com.", "CNAME admin.example.com."})
customs := cs.Customs()
if len(customs) != 1 {
t.Fatalf("expected exactly 1 custom diff, got %d: %+v", len(customs), customs)
}
if customs[0].Key() != "CNAME admin.example.com." {
t.Fatalf("expected the Delete diff to be custom, got %q", customs[0].Key())
}
// Шаблон побеждает: описанный шаблоном ключ остаётся обычным Update.
updates := cs.Updates()
if len(updates) != 1 || updates[0].Key() != "A www.example.com." {
t.Fatalf("expected the templated record to stay in Updates, got %+v", updates)
}
}
func TestCustomDiffsLeaveActionableAndPrunes(t *testing.T) {
actual := []model.Record{
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
}
cs := diff.Diff(nil, actual)
if len(cs.Prunes()) != 1 {
t.Fatalf("precondition: expected 1 prune before marking, got %+v", cs.Prunes())
}
cs.MarkCustom([]string{"CNAME admin.example.com."})
if got := cs.Prunes(); len(got) != 0 {
t.Fatalf("expected custom diff to leave Prunes, got %+v", got)
}
if got := cs.Actionable(); len(got) != 0 {
t.Fatalf("expected custom diff to leave Actionable, got %+v", got)
}
if got := cs.Customs(); len(got) != 1 {
t.Fatalf("expected 1 custom diff, got %+v", got)
}
}
func TestMarkCustomNeverMarksReadOnly(t *testing.T) {
actual := []model.Record{
{Type: "NS", Name: "example.com.", TTL: 300, Values: []string{"ns1.example.com."}},
}
cs := diff.Diff(nil, actual)
cs.MarkCustom([]string{"NS example.com."})
if got := cs.Customs(); len(got) != 0 {
t.Fatalf("read-only diffs must never become custom, got %+v", got)
}
}
- Step 2: Запустить тесты, убедиться что падают
Run: go test ./internal/diff/ -run 'Custom' -v
Expected: FAIL — cs.MarkCustom undefined, cs.Customs undefined.
- Step 3: Реализовать
В internal/diff/diff.go в структуру RecordDiff добавить поле после ReadOnly:
ReadOnly bool // NS/SOA — shown but never applied
// Custom marks a record the operator deliberately keeps outside the
// template: shown in its own section, never counted as drift, never
// applied. Set by MarkCustom, not by Diff.
Custom bool
Заменить фильтры в Actionable, Updates, Prunes — везде, где стоит if d.ReadOnly, писать if d.ReadOnly || d.Custom (в Actionable условие становится if d.ReadOnly || d.Custom || d.Kind == InSync).
Добавить в конец файла:
// MarkCustom flags the diffs whose Key() is in keys as Custom. Only
// Kind == Delete diffs are marked, and never read-only ones: the template
// wins. As soon as the template starts describing a key, its diff becomes
// Add/Update/InSync and the stored mark stops having any effect (it is not
// deleted — the operator may go back to a template without that record).
func (c *Changeset) MarkCustom(keys []string) {
if len(keys) == 0 {
return
}
set := make(map[string]bool, len(keys))
for _, k := range keys {
set[k] = true
}
for i := range c.Diffs {
d := &c.Diffs[i]
if d.ReadOnly || d.Kind != Delete {
continue
}
if set[d.Key()] {
d.Custom = true
}
}
}
// Customs returns diffs marked by MarkCustom. Disjoint from Updates() and
// Prunes(), and outside Actionable() — a custom record is never drift.
func (c Changeset) Customs() []RecordDiff {
var out []RecordDiff
for _, d := range c.Diffs {
if d.Custom {
out = append(out, d)
}
}
return out
}
- Step 4: Запустить тесты пакета
Run: go test ./internal/diff/ -v
Expected: PASS, включая существующие тесты Actionable/Updates/Prunes.
- Step 5: Коммит
git add internal/diff/diff.go internal/diff/diff_test.go
git commit -m "feat(diff): mark records deliberately kept outside the template"
Task 2: Хранилище пометок
Files:
- Create:
internal/store/migrations/0005_domain_custom_records.sql - Create:
internal/store/queries/customs.sql - Create:
internal/store/db/customs.sql.go - Modify:
internal/store/db/models.go - Modify:
internal/store/tenant.go - Test:
internal/store/store_test.go
Interfaces:
-
Consumes: ничего из Task 1.
-
Produces:
func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error,func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error,func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error). -
Step 1: Написать падающие тесты
Добавить в internal/store/store_test.go (файл уже содержит хелпер поднятия контейнера — смотри существующие тесты и переиспользуй тот же способ получения *store.Store и созданных project/account/domain):
func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) {
ctx := context.Background()
s := newTestStore(t) // существующий хелпер из этого файла
_, _, dom := seedDomain(t, s) // существующий хелпер: project, account, domain
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
// Повторное добавление того же ключа не должно падать на PK-конфликте.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatalf("second add must be a no-op, got %v", err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0] != key {
t.Fatalf("expected exactly [%q], got %+v", key, keys)
}
// Чужой проект не может пометить этот домен.
if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil {
t.Fatal("expected an error when marking a domain from another project")
}
}
func TestCustomRecordsDeleteAndCascade(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
_, _, dom := seedDomain(t, s)
const key = "CNAME admin.example.com."
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
keys, err := s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected no keys after delete, got %+v", keys)
}
// Каскад: удаление домена уносит его пометки.
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
t.Fatal(err)
}
if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil {
t.Fatal(err)
}
keys, err = s.ListCustomKeys(ctx, dom.ID)
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
}
}
Если хелперов newTestStore/seedDomain в файле нет под такими именами — использовать те, что есть (см. internal/store/testhelper_test.go и соседние тесты), не изобретая новых.
- Step 2: Запустить тесты, убедиться что падают
Run: go test ./internal/store/ -run Custom -v (нужен запущенный Docker)
Expected: FAIL — s.AddCustomRecord undefined.
- Step 3: Миграция
Создать internal/store/migrations/0005_domain_custom_records.sql:
-- +goose Up
CREATE TABLE domain_custom_records (
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
record_key text NOT NULL,
note text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (domain_id, record_key)
);
-- +goose Down
DROP TABLE domain_custom_records;
- Step 4: Запросы
Создать internal/store/queries/customs.sql:
-- name: AddCustomRecord :exec
INSERT INTO domain_custom_records (domain_id, record_key)
VALUES ($1, $2)
ON CONFLICT (domain_id, record_key) DO NOTHING;
-- name: DeleteCustomRecord :exec
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2;
-- name: ListCustomKeys :many
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key;
- Step 5: Написать руками сгенерированный код
Создать internal/store/db/customs.sql.go:
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: customs.sql
package db
import (
"context"
"github.com/google/uuid"
)
const addCustomRecord = `-- name: AddCustomRecord :exec
INSERT INTO domain_custom_records (domain_id, record_key)
VALUES ($1, $2)
ON CONFLICT (domain_id, record_key) DO NOTHING
`
type AddCustomRecordParams struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
}
func (q *Queries) AddCustomRecord(ctx context.Context, arg AddCustomRecordParams) error {
_, err := q.db.Exec(ctx, addCustomRecord, arg.DomainID, arg.RecordKey)
return err
}
const deleteCustomRecord = `-- name: DeleteCustomRecord :exec
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2
`
type DeleteCustomRecordParams struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
}
func (q *Queries) DeleteCustomRecord(ctx context.Context, arg DeleteCustomRecordParams) error {
_, err := q.db.Exec(ctx, deleteCustomRecord, arg.DomainID, arg.RecordKey)
return err
}
const listCustomKeys = `-- name: ListCustomKeys :many
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key
`
func (q *Queries) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listCustomKeys, domainID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var record_key string
if err := rows.Scan(&record_key); err != nil {
return nil, err
}
items = append(items, record_key)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
В internal/store/db/models.go добавить модель (рядом с остальными, по алфавиту после CheckRun):
type DomainCustomRecord struct {
DomainID uuid.UUID `json:"domain_id"`
RecordKey string `json:"record_key"`
Note string `json:"note"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
- Step 6: Обёртки в store
В internal/store/tenant.go рядом с доменными методами добавить:
// AddCustomRecord marks an RRset (identified by its diff key, "TYPE name.")
// as deliberately kept outside the template for this domain. Idempotent.
// The scoped GetDomain first ensures the domain belongs to projectID —
// otherwise a foreign domain could be marked (IDOR-on-write), same guard
// SetDomainTemplate uses for templates.
func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
return err
}
return s.q.AddCustomRecord(ctx, db.AddCustomRecordParams{DomainID: domainID, RecordKey: key})
}
// DeleteCustomRecord removes the mark, returning the record to the regular
// diff. Scoped by projectID for the same reason as AddCustomRecord.
func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
return err
}
return s.q.DeleteCustomRecord(ctx, db.DeleteCustomRecordParams{DomainID: domainID, RecordKey: key})
}
// ListCustomKeys returns the marked record keys of a domain. Not scoped by
// project on purpose: the only caller is LoadDomain, which has already
// resolved the domain by (id, projectID).
func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
return s.q.ListCustomKeys(ctx, domainID)
}
- Step 7: Запустить тесты
Run: go test ./internal/store/ -run Custom -v
Expected: PASS.
- Step 8: Коммит
git add internal/store/migrations/0005_domain_custom_records.sql internal/store/queries/customs.sql internal/store/db/customs.sql.go internal/store/db/models.go internal/store/tenant.go internal/store/store_test.go
git commit -m "feat(store): persist per-domain custom record marks"
Task 3: Пометки доезжают до диффа
Files:
- Modify:
internal/service/service.go - Modify:
internal/store/loader.go - Test:
internal/service/service_test.go
Interfaces:
-
Consumes:
Changeset.MarkCustom(Task 1),Store.ListCustomKeys(Task 2). -
Produces: поле
service.DomainRef.CustomKeys []string, заполняемоеstore.LoadDomain;service.resolveвызываетcs.MarkCustom(ref.CustomKeys). -
Step 1: Написать падающие тесты
Добавить в internal/service/service_test.go (использовать существующие фейки лоадера/провайдера из этого файла — не создавать новые):
func TestResolveMarksCustomKeys(t *testing.T) {
// Зона содержит запись, которой нет в шаблоне, и её ключ помечен.
svc, _ := newTestService(t, testDomainRef{
template: dto.TemplateDoc{Records: nil},
zone: []model.Record{
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
},
customKeys: []string{"CNAME admin.example.com."},
})
cs, err := svc.Check(context.Background(), testProjectID, testDomainID)
if err != nil {
t.Fatal(err)
}
if got := cs.Prunes(); len(got) != 0 {
t.Fatalf("custom record must not appear in Prunes, got %+v", got)
}
if got := cs.Customs(); len(got) != 1 {
t.Fatalf("expected 1 custom diff, got %+v", got)
}
if status := service.DeriveStatus(cs); status != service.StatusInSync {
t.Fatalf("a zone whose only deviation is custom must be in_sync, got %q", status)
}
}
func TestApplyIgnoresCustomKeySentAsPrune(t *testing.T) {
svc, prov := newTestService(t, testDomainRef{
template: dto.TemplateDoc{Records: nil},
zone: []model.Record{
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
},
customKeys: []string{"CNAME admin.example.com."},
})
applied, err := svc.Apply(context.Background(), testProjectID, testDomainID, service.ApplyRequest{
Prunes: []string{"CNAME admin.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(applied.Diffs) != 0 {
t.Fatalf("custom key must never be applied, got %+v", applied.Diffs)
}
if prov.applyCalls != 0 {
t.Fatalf("provider must not be called with an empty change set, got %d calls", prov.applyCalls)
}
}
Хелпер newTestService/testDomainRef в файле уже есть в том или ином виде — расширить его структуру полем customKeys, которое попадает в возвращаемый service.DomainRef. Имена подогнать под существующий код файла, поведение сохранить.
- Step 2: Запустить тесты, убедиться что падают
Run: go test ./internal/service/ -run 'Custom' -v
Expected: FAIL — у DomainRef нет поля CustomKeys.
- Step 3: Реализовать
В internal/service/service.go в DomainRef добавить поле:
type DomainRef struct {
ZoneID string
ZoneName string
Provider string
SecretEnc string
Template dto.TemplateDoc
// CustomKeys are RecordDiff keys the operator marked as deliberately
// outside the template for this domain. Applied in resolve() right after
// the diff is computed — the single marking point, like tmpl.Materialize
// is the single materialisation point.
CustomKeys []string
}
В resolve() заменить последнюю пару строк:
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
cs.MarkCustom(ref.CustomKeys)
return p, creds, ref, cs, nil
В internal/store/loader.go в LoadDomain перед return дочитать ключи:
keys, err := s.ListCustomKeys(ctx, domainID)
if err != nil {
return service.DomainRef{}, err
}
return service.DomainRef{
ZoneID: row.ZoneID,
ZoneName: row.ZoneName,
Provider: row.Provider,
SecretEnc: row.SecretEnc,
Template: *row.Doc,
CustomKeys: keys,
}, nil
- Step 4: Запустить тесты
Run: go test ./internal/service/ ./internal/diff/ -v
Expected: PASS.
- Step 5: Коммит
git add internal/service/service.go internal/service/service_test.go internal/store/loader.go
git commit -m "feat(service): apply custom marks to the computed diff"
Task 4: API пометок
Files:
- Modify:
internal/api/api.go(интерфейсTenantStore+ роуты) - Modify:
internal/api/handlers.go - Modify:
internal/api/dto.go - Test:
internal/api/api_test.go
Interfaces:
-
Consumes:
Store.AddCustomRecord/DeleteCustomRecord(Task 2),Changeset.Customs()(Task 1). -
Produces:
POST /api/projects/{pid}/domains/{did}/customs(тело{"key":"..."}, 201),DELETE /api/projects/{pid}/domains/{did}/customs?key=...(204); полеcustomsвchangesetResponseиcustomвrecordView. -
Step 1: Написать падающие тесты
Добавить в internal/api/api_test.go (переиспользовать существующий способ сборки роутера с моком TenantStore — добавить в мок две новые функции-поля):
func TestAddCustomRecordReturns201(t *testing.T) {
var gotDomain, gotProject uuid.UUID
var gotKey string
st := newFakeTenantStore()
st.addCustomRecord = func(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
gotDomain, gotProject, gotKey = domainID, projectID, key
return nil
}
srv := newTestAPI(t, st)
rr := srv.do(t, http.MethodPost,
"/api/projects/"+testProjectID.String()+"/domains/"+testDomainID.String()+"/customs",
`{"key":"CNAME admin.example.com."}`)
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
if gotDomain != testDomainID || gotProject != testProjectID {
t.Fatalf("handler must scope by (domain, project), got (%s, %s)", gotDomain, gotProject)
}
if gotKey != "CNAME admin.example.com." {
t.Fatalf("unexpected key %q", gotKey)
}
}
func TestAddCustomRecordRejectsEmptyKey(t *testing.T) {
st := newFakeTenantStore()
called := false
st.addCustomRecord = func(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
called = true
return nil
}
srv := newTestAPI(t, st)
rr := srv.do(t, http.MethodPost,
"/api/projects/"+testProjectID.String()+"/domains/"+testDomainID.String()+"/customs",
`{"key":""}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 on empty key, got %d", rr.Code)
}
if called {
t.Fatal("store must not be called with an empty key")
}
}
func TestDeleteCustomRecordReturns204(t *testing.T) {
var gotKey string
st := newFakeTenantStore()
st.deleteCustomRecord = func(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
gotKey = key
return nil
}
srv := newTestAPI(t, st)
rr := srv.do(t, http.MethodDelete,
"/api/projects/"+testProjectID.String()+"/domains/"+testDomainID.String()+
"/customs?key="+url.QueryEscape("CNAME admin.example.com."), "")
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String())
}
if gotKey != "CNAME admin.example.com." {
t.Fatalf("key must be url-decoded, got %q", gotKey)
}
}
func TestAddCustomRecordForeignDomainReturns404(t *testing.T) {
st := newFakeTenantStore()
st.addCustomRecord = func(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
return pgx.ErrNoRows
}
srv := newTestAPI(t, st)
rr := srv.do(t, http.MethodPost,
"/api/projects/"+testProjectID.String()+"/domains/"+testDomainID.String()+"/customs",
`{"key":"CNAME admin.example.com."}`)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a domain outside the project, got %d", rr.Code)
}
}
func TestCheckResponseCustomsIsAlwaysArray(t *testing.T) {
// Пустой changeset: поле customs должно сериализоваться как [], не null.
srv := newTestAPIWithChangeset(t, diff.Changeset{})
rr := srv.do(t, http.MethodGet,
"/api/projects/"+testProjectID.String()+"/domains/"+testDomainID.String()+"/check", "")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `"customs":[]`) {
t.Fatalf("customs must marshal as [], got %s", rr.Body.String())
}
}
Имена хелперов (newFakeTenantStore, newTestAPI, srv.do, newTestAPIWithChangeset) подогнать под то, что уже есть в api_test.go; не плодить второй набор хелперов.
- Step 2: Запустить тесты, убедиться что падают
Run: go test ./internal/api/ -run Custom -v
Expected: FAIL — 404/405 на новых маршрутах, у мока нет полей.
- Step 3: Расширить DTO
В internal/api/dto.go:
type customRequest struct {
Key string `json:"key"`
}
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"`
Custom bool `json:"custom"`
}
type changesetResponse struct {
Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"`
Customs []recordView `json:"customs"`
ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"`
}
В toRecordView добавить Custom: d.Custom к инициализации. В toChangesetResponse добавить Customs: []recordView{} в инициализацию resp и цикл после prunes:
for _, d := range cs.Customs() {
resp.Customs = append(resp.Customs, toRecordView(d))
}
- Step 4: Хендлеры
В internal/api/handlers.go добавить (рядом с handleApply):
// handleAddCustom marks an RRset as deliberately kept outside the template:
// it moves from Prunes to its own section, stops counting as drift and can
// never be applied. The key travels in the body, not the path — it contains
// a space and dots ("CNAME admin.example.com.").
func (a *API) handleAddCustom(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
var req customRequest
if !decodeBody(w, r, &req) {
return
}
if req.Key == "" {
writeErr(w, http.StatusBadRequest, "key is required")
return
}
if err := a.Store.AddCustomRecord(r.Context(), did, pid, req.Key); err != nil {
// Either the domain doesn't exist or it belongs to another project —
// both are "not found" from this tenant's point of view.
log.Printf("api: add custom record failed: %v", err)
writeErr(w, http.StatusNotFound, "domain not found")
return
}
w.WriteHeader(http.StatusCreated)
}
// handleDeleteCustom removes the mark, returning the record to the regular
// diff. The key comes as a query parameter for the same reason it comes in
// the body on POST.
func (a *API) handleDeleteCustom(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
key := r.URL.Query().Get("key")
if key == "" {
writeErr(w, http.StatusBadRequest, "key is required")
return
}
if err := a.Store.DeleteCustomRecord(r.Context(), did, pid, key); err != nil {
log.Printf("api: delete custom record failed: %v", err)
writeErr(w, http.StatusNotFound, "domain not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
- Step 5: Интерфейс и роуты
В internal/api/api.go в TenantStore после SetDomainStatus добавить:
// AddCustomRecord/DeleteCustomRecord manage the per-domain marks that keep
// a record out of the diff. Scoped by projectID: marking a domain outside
// the caller's project must fail (IDOR-on-write).
AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
В роутере, в блоке r.Route("/{did}", ...) рядом с /apply и /check:
r.Post("/customs", a.handleAddCustom)
r.Delete("/customs", a.handleDeleteCustom)
- Step 6: Запустить тесты
Run: go test ./internal/api/ -v
Expected: PASS (включая существующие тесты чека/аплая).
- Step 7: Коммит
git add internal/api/api.go internal/api/dto.go internal/api/handlers.go internal/api/api_test.go
git commit -m "feat(api): endpoints to mark and unmark custom records"
Task 5: Клиент и хуки на фронте
Files:
- Modify:
web/src/api/types.ts - Modify:
web/src/api/client.ts - Modify:
web/src/hooks/useApi.ts
Interfaces:
-
Consumes: маршруты из Task 4.
-
Produces:
api.addCustom(projectId, domainId, key),api.removeCustom(projectId, domainId, key), хукиuseAddCustom(domainId),useRemoveCustom(domainId); поляcustoms/customв типах. -
Step 1: Типы
В web/src/api/types.ts:
export interface RecordView {
key: string // stable "TYPE name." identifier — used to select this record for Apply
kind: string // add | update | delete | in_sync
type: string
name: string
desired?: string[]
actual?: string[]
readOnly: boolean
custom: boolean // помечена оператором как живущая вне шаблона
}
export interface ChangesetResponse {
updates: RecordView[]
prunes: RecordView[]
customs: RecordView[]
readOnly: RecordView[]
inSyncCount: number
}
- Step 2: Клиент
В web/src/api/client.ts после applyDomain:
addCustom: (projectId: string, id: string, key: string) =>
req<void>(projectPath(projectId, `/domains/${id}/customs`), {
method: "POST",
body: JSON.stringify({ key }),
}),
removeCustom: (projectId: string, id: string, key: string) =>
req<void>(
projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`),
{ method: "DELETE" },
),
- Step 3: Хуки
В web/src/hooks/useApi.ts после useApplyDomain:
export function useAddCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.addCustom(pid, id, key)
},
// Дифф пересчитывается на бэкенде — инвалидируем чек, а заодно статус
// домена в списке (custom-запись перестаёт быть дрифтом).
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
export function useRemoveCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.removeCustom(pid, id, key)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
- Step 4: Проверить типы
Run: cd web && npx tsc --noEmit
Expected: ошибки только в тестах/компонентах, где ChangesetResponse конструируется без нового обязательного поля customs — это чинится в Task 6. Если ошибок нет вовсе — тоже нормально.
- Step 5: Коммит
git add web/src/api/types.ts web/src/api/client.ts web/src/hooks/useApi.ts
git commit -m "feat(web): api client and hooks for custom record marks"
Task 6: Секция CUSTOMS в UI
Files:
- Modify:
web/src/components/DiffView.tsx - Modify:
web/src/pages/DomainDiffPage.tsx - Modify:
web/src/index.css - Test:
web/src/components/DiffView.test.tsx(создать, если файла нет),web/src/pages/DomainDiffPage.test.tsx
Interfaces:
-
Consumes:
useAddCustom/useRemoveCustom(Task 5), полеcustomsв ответе чека (Task 4). -
Produces: тон
customвDiffView, пропсыonMarkCustom?: (key: string) => voidиonUnmarkCustom?: (key: string) => void. -
Step 1: Написать падающие тесты
В web/src/pages/DomainDiffPage.test.tsx добавить (мок чека уже есть в файле — расширить его объект полем customs):
test("кнопка «В customs» на строке prune вызывает api.addCustom с ключом записи", async () => {
const addSpy = vi.spyOn(api, "addCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: false },
],
customs: [],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /в customs cname admin\.example\.com\./i }))
await waitFor(() =>
expect(addSpy).toHaveBeenCalledWith(PROJECT_ID, DOMAIN_ID, "CNAME admin.example.com."),
)
})
test("кнопка «Вернуть в дифф» на строке custom вызывает api.removeCustom", async () => {
const removeSpy = vi.spyOn(api, "removeCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /вернуть в дифф cname admin\.example\.com\./i }))
await waitFor(() =>
expect(removeSpy).toHaveBeenCalledWith(PROJECT_ID, DOMAIN_ID, "CNAME admin.example.com."),
)
})
test("в секции customs нет чекбоксов — такие записи не применяются", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
renderPage()
const section = await screen.findByRole("region", { name: /customs/i })
expect(within(section).queryByRole("checkbox")).toBeNull()
})
Существующие моки checkDomain в этом файле дополнить полем customs: [], иначе TypeScript не соберёт тест.
- Step 2: Запустить тесты, убедиться что падают
Run: cd web && npx vitest run src/pages/DomainDiffPage.test.tsx
Expected: FAIL — кнопки не найдены.
- Step 3: CSS-токен
В web/src/index.css рядом с --diff-readonly (в обеих темах — светлой и тёмной) и в блоке --color-diff-*:
--color-diff-custom: var(--diff-custom);
--diff-custom: oklch(0.62 0.11 250); /* muted blue */
Тёмная тема — тем же значением в соответствующем блоке (см. как продублирован --diff-readonly).
- Step 4: DiffView
В web/src/components/DiffView.tsx:
import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react"
type Tone = "update" | "delete" | "custom" | "readonly"
В TONE_META добавить перед readonly:
custom: {
label: "Customs",
empty: "Нет записей, помеченных как осознанные.",
icon: BookmarkCheck,
dot: "var(--diff-custom)",
ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]",
},
RecordRow получает слот действия. В пропсы добавить action?: ReactNode, в верхней строке — после бейджа read-only:
{action}
Section прокидывает действие построчно — добавить проп renderAction?: (record: RecordView) => ReactNode и передавать action={renderAction?.(record)} в RecordRow. Секция custom не селектируемая: в selectable условие становится
const selectable =
tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll
В DiffView добавить пропсы и секцию:
onMarkCustom,
onUnmarkCustom,
}: {
// ...существующие пропсы...
onMarkCustom?: (key: string) => void
onUnmarkCustom?: (key: string) => void
footerExtra?: ReactNode
}) {
<Section
tone="delete"
records={changeset.prunes ?? []}
selected={selectedPrunes}
onToggle={onTogglePrune}
onToggleAll={onToggleAllPrunes}
renderAction={
onMarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`В customs ${record.type} ${record.name}`}
onClick={() => onMarkCustom(record.key)}
>
<BookmarkCheck className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section
tone="custom"
records={changeset.customs ?? []}
renderAction={
onUnmarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Вернуть в дифф ${record.type} ${record.name}`}
onClick={() => onUnmarkCustom(record.key)}
>
<Undo2 className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section tone="readonly" records={changeset.readOnly ?? []} />
Импортировать Button из @/components/ui/button.
- Step 5: Страница
В web/src/pages/DomainDiffPage.tsx:
import { useAddCustom, useRemoveCustom } from "@/hooks/useApi" // добавить к существующему импорту хуков
const addCustom = useAddCustom(id)
const removeCustom = useRemoveCustom(id)
и в разметку DiffView:
onMarkCustom={(key) => addCustom.mutate(key)}
onUnmarkCustom={(key) => removeCustom.mutate(key)}
Под блоком с ошибкой применения добавить вывод ошибок мутаций:
{(addCustom.isError || removeCustom.isError) && (
<span role="alert" className="font-dns text-xs text-destructive">
{(addCustom.error ?? removeCustom.error)?.message}
</span>
)}
- Step 6: Запустить тесты и типы
Run: cd web && npx tsc --noEmit && npm run test -- --run
Expected: PASS, TypeScript чист.
- Step 7: Коммит
git add web/src/components/DiffView.tsx web/src/pages/DomainDiffPage.tsx web/src/pages/DomainDiffPage.test.tsx web/src/index.css
git commit -m "feat(web): customs section with mark and unmark actions"
Task 7: Импорт удаляет домены исчезнувших зон
Files:
- Modify:
internal/store/queries/domains.sql - Modify:
internal/store/db/domains.sql.go - Modify:
internal/store/tenant.go:199-227(ImportDomains) - Test:
internal/store/store_test.go
Interfaces:
-
Consumes: ничего из предыдущих задач.
-
Produces: сигнатура меняется на
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) (created []Domain, removed []Domain, err error). -
Step 1: Написать падающие тесты
func TestImportDomainsRemovesVanishedZones(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
proj, acc := seedProjectAndAccount(t, s) // существующие хелперы файла
created, removed, err := s.ImportDomains(ctx, proj.ID, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
{ID: "z2", Name: "two.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 2 || len(removed) != 0 {
t.Fatalf("first import: expected 2 created / 0 removed, got %d/%d", len(created), len(removed))
}
// z2 удалили у провайдера — переимпорт должен убрать её домен.
created, removed, err = s.ImportDomains(ctx, proj.ID, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(created) != 0 {
t.Fatalf("expected nothing new to be created, got %+v", created)
}
if len(removed) != 1 || removed[0].ZoneID != "z2" {
t.Fatalf("expected the vanished zone's domain to be removed, got %+v", removed)
}
left, err := s.ListDomains(ctx, proj.ID)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].ZoneID != "z1" {
t.Fatalf("expected only z1 to survive, got %+v", left)
}
}
func TestImportDomainsEmptyZoneListRemovesNothing(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
proj, acc := seedProjectAndAccount(t, s)
if _, _, err := s.ImportDomains(ctx, proj.ID, acc.ID, []provider.Zone{
{ID: "z1", Name: "one.example.com."},
}); err != nil {
t.Fatal(err)
}
// Пустой ответ провайдера неотличим от временной потери доступа —
// удалять по нему нельзя.
created, removed, err := s.ImportDomains(ctx, proj.ID, acc.ID, nil)
if err != nil {
t.Fatal(err)
}
if len(created) != 0 || len(removed) != 0 {
t.Fatalf("empty zone list must be a no-op, got %d created / %d removed", len(created), len(removed))
}
left, err := s.ListDomains(ctx, proj.ID)
if err != nil {
t.Fatal(err)
}
if len(left) != 1 {
t.Fatalf("expected the domain to survive an empty zone list, got %+v", left)
}
}
func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
proj, accA := seedProjectAndAccount(t, s)
accB := seedAccount(t, s, proj.ID) // второй provider-аккаунт того же проекта
if _, _, err := s.ImportDomains(ctx, proj.ID, accB.ID, []provider.Zone{
{ID: "zb", Name: "b.example.com."},
}); err != nil {
t.Fatal(err)
}
if _, _, err := s.ImportDomains(ctx, proj.ID, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
}); err != nil {
t.Fatal(err)
}
// Импорт по accA с одной зоной не должен трогать домены accB.
_, removed, err := s.ImportDomains(ctx, proj.ID, accA.ID, []provider.Zone{
{ID: "za", Name: "a.example.com."},
})
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("import for one account must not remove another account's domains, got %+v", removed)
}
left, err := s.ListDomains(ctx, proj.ID)
if err != nil {
t.Fatal(err)
}
if len(left) != 2 {
t.Fatalf("expected both accounts' domains to survive, got %+v", left)
}
}
Хелперы seedProjectAndAccount/seedAccount — использовать существующие из internal/store-тестов; если второго аккаунта там завести нечем, создать его через s.CreateAccount(ctx, proj.ID, "selectel", "enc", "second").
- Step 2: Запустить тесты, убедиться что падают
Run: go test ./internal/store/ -run Import -v
Expected: FAIL — компиляция: ImportDomains возвращает два значения, а не три.
- Step 3: Запрос удаления
В internal/store/queries/domains.sql добавить:
-- name: DeleteDomainsNotInZones :many
DELETE FROM domains
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
RETURNING *;
- Step 4: Написать руками сгенерированный код
В internal/store/db/domains.sql.go добавить (порядок колонок в RETURNING, в Scan и в Domain совпадает 1:1 — см. listDomains выше по файлу):
const deleteDomainsNotInZones = `-- name: DeleteDomainsNotInZones :many
DELETE FROM domains
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status
`
type DeleteDomainsNotInZonesParams struct {
ProjectID uuid.UUID `json:"project_id"`
ProviderAccountID uuid.UUID `json:"provider_account_id"`
ZoneIds []string `json:"zone_ids"`
}
func (q *Queries) DeleteDomainsNotInZones(ctx context.Context, arg DeleteDomainsNotInZonesParams) ([]Domain, error) {
rows, err := q.db.Query(ctx, deleteDomainsNotInZones, arg.ProjectID, arg.ProviderAccountID, arg.ZoneIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Domain
for rows.Next() {
var i Domain
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.ProviderAccountID,
&i.ZoneName,
&i.ZoneID,
&i.TemplateID,
&i.CreatedAt,
&i.LastCheckStatus,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
- Step 5: Синхронизация в ImportDomains
Заменить тело ImportDomains в internal/store/tenant.go, обновив доккоммент:
// ImportDomains synchronises a provider account's zones with the domains
// stored for it, inside a single transaction: zones without a domain are
// created (ON CONFLICT DO NOTHING keeps repeated imports idempotent), and
// domains whose zone no longer exists at the provider are deleted. Deletion
// is scoped to accountID — a project may hold several provider accounts, and
// one account's zone list says nothing about another's domains.
//
// An empty zone list removes nothing: it is indistinguishable from an account
// that temporarily lost access to its zones, and the cost of being wrong is
// every domain of the account gone along with its check history.
//
// Returns the domains actually created and the ones actually removed.
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, []Domain, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, nil, err
}
defer tx.Rollback(ctx) // no-op once Commit has succeeded
q := s.q.WithTx(tx)
created := make([]Domain, 0, len(zones))
zoneIDs := make([]string, 0, len(zones))
for _, z := range zones {
zoneIDs = append(zoneIDs, z.ID)
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// ON CONFLICT DO NOTHING: this zone was already imported
// for this project — skip it rather than fail the batch.
continue
}
return nil, nil, err
}
created = append(created, domainFromDB(d))
}
removed := make([]Domain, 0)
if len(zoneIDs) > 0 {
gone, err := q.DeleteDomainsNotInZones(ctx, db.DeleteDomainsNotInZonesParams{
ProjectID: projectID, ProviderAccountID: accountID, ZoneIds: zoneIDs,
})
if err != nil {
return nil, nil, err
}
for _, d := range gone {
removed = append(removed, domainFromDB(d))
}
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
return created, removed, nil
}
- Step 6: Запустить тесты
Run: go test ./internal/store/ -run Import -v
Expected: PASS. go build ./... при этом ещё падает в internal/api — это чинится в Task 8.
- Step 7: Коммит
Коммитить вместе с Task 8 — в промежутке проект не собирается. Перейти к Task 8 без коммита.
Task 8: Ответ импорта {created, removed}
Files:
- Modify:
internal/api/api.go(сигнатураImportDomainsвTenantStore) - Modify:
internal/api/tenant_dto.go - Modify:
internal/api/tenant_handlers.go:99-146(handleImportZones) - Test:
internal/api/tenant_test.go
Interfaces:
-
Consumes:
Store.ImportDomainsс тремя возвращаемыми значениями (Task 7). -
Produces:
POST /accounts/{aid}/importотдаёт{"created": [...], "removed": [...]}вместо голого массива. -
Step 1: Написать падающий тест
В internal/api/tenant_test.go (существующий тест импорта, ожидающий массив, тоже нужно поправить под новый формат):
func TestImportZonesReturnsCreatedAndRemoved(t *testing.T) {
st := newFakeTenantStore()
st.importDomains = func(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
return []store.Domain{{ID: uuid.New(), ZoneName: "new.example.com.", ZoneID: "z1"}},
[]store.Domain{{ID: uuid.New(), ZoneName: "gone.example.com.", ZoneID: "z9"}},
nil
}
srv := newTestAPI(t, st)
rr := srv.do(t, http.MethodPost,
"/api/projects/"+testProjectID.String()+"/accounts/"+testAccountID.String()+"/import", "")
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var got struct {
Created []struct{ ZoneName string `json:"zoneName"` } `json:"created"`
Removed []struct{ ZoneName string `json:"zoneName"` } `json:"removed"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("response must be an object with created/removed: %v (%s)", err, rr.Body.String())
}
if len(got.Created) != 1 || got.Created[0].ZoneName != "new.example.com." {
t.Fatalf("unexpected created: %+v", got.Created)
}
if len(got.Removed) != 1 || got.Removed[0].ZoneName != "gone.example.com." {
t.Fatalf("unexpected removed: %+v", got.Removed)
}
}
- Step 2: Запустить, убедиться что падает
Run: go test ./internal/api/ -run Import -v
Expected: FAIL — компиляция мока (сигнатура) и формат ответа.
- Step 3: DTO
В internal/api/tenant_dto.go:
// importResponse splits an import into what appeared and what vanished at
// the provider. Both slices are initialised so they marshal as [] and never
// as null — clients call .length/.map on them.
type importResponse struct {
Created []domainResponse `json:"created"`
Removed []domainResponse `json:"removed"`
}
- Step 4: Хендлер и интерфейс
В internal/api/api.go поправить строку интерфейса:
// ImportDomains synchronises an account's zones with its domains: new
// zones become domains, domains of vanished zones are deleted. Returns
// (created, removed).
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
В internal/api/tenant_handlers.go заменить хвост handleImportZones:
doms, gone, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
if err != nil {
log.Printf("api: import: sync domains failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := importResponse{
Created: make([]domainResponse, 0, len(doms)),
Removed: make([]domainResponse, 0, len(gone)),
}
for _, d := range doms {
resp.Created = append(resp.Created, toDomainResponse(d))
}
for _, d := range gone {
resp.Removed = append(resp.Removed, toDomainResponse(d))
}
writeJSON(w, http.StatusCreated, resp)
- Step 5: Запустить тесты и сборку
Run: go build ./... && go test ./internal/api/ ./internal/service/ ./internal/diff/ -v
Expected: PASS.
- Step 6: Коммит
git add internal/store/queries/domains.sql internal/store/db/domains.sql.go internal/store/tenant.go internal/store/store_test.go internal/api/api.go internal/api/tenant_dto.go internal/api/tenant_handlers.go internal/api/tenant_test.go
git commit -m "feat: remove domains whose zones vanished at the provider on import"
Task 9: Фронт под новый ответ импорта
Files:
- Modify:
web/src/api/types.ts - Modify:
web/src/api/client.ts - Modify:
web/src/pages/DomainsPage.tsx - Test:
web/src/pages/DomainsPage.test.tsx
Interfaces:
-
Consumes:
{created, removed}отPOST /accounts/{aid}/import(Task 8). -
Produces: тип
ImportResult,api.importZonesвозвращаетImportResult. -
Step 1: Написать падающий тест
В web/src/pages/DomainsPage.test.tsx:
test("после импорта показывает, сколько зон создано и сколько удалено", async () => {
vi.spyOn(api, "importZones").mockResolvedValue({
created: [
{ id: "d1", providerAccountId: "a1", zoneName: "new.example.com.", zoneId: "z1" },
],
removed: [
{ id: "d2", providerAccountId: "a1", zoneName: "gone.example.com.", zoneId: "z9" },
],
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /импортировать зоны/i }))
expect(await screen.findByText(/создано 1, удалено 1/i)).toBeInTheDocument()
})
- Step 2: Запустить, убедиться что падает
Run: cd web && npx vitest run src/pages/DomainsPage.test.tsx
Expected: FAIL — текста нет.
- Step 3: Тип и клиент
В web/src/api/types.ts:
export interface ImportResult { created: Domain[]; removed: Domain[] }
В web/src/api/client.ts заменить importZones:
importZones: (projectId: string, accountId: string) =>
req<ImportResult>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
и добавить ImportResult в список импортируемых типов в шапке файла.
- Step 4: Страница
В web/src/pages/DomainsPage.tsx после блока с ошибкой импорта:
{importZones.isSuccess && (
<span className="font-dns text-xs text-muted-foreground">
Создано {importZones.data.created.length}, удалено {importZones.data.removed.length}
</span>
)}
- Step 5: Запустить тесты и типы
Run: cd web && npx tsc --noEmit && npm run test -- --run
Expected: PASS.
- Step 6: Коммит
git add web/src/api/types.ts web/src/api/client.ts web/src/pages/DomainsPage.tsx web/src/pages/DomainsPage.test.tsx
git commit -m "feat(web): show created and removed counts after zone import"
Task 10: Документация и финальная проверка
Files:
- Modify:
CLAUDE.md - Modify:
README.md
Interfaces:
-
Consumes: всё вышеперечисленное.
-
Produces: ничего для кода.
-
Step 1: Инварианты в CLAUDE.md
В раздел «Инварианты (нарушать нельзя)» добавить два пункта:
- **Custom-записи.** Пометка живёт в `domain_custom_records` и привязана к домену. `Changeset.MarkCustom` метит только `Kind == Delete` и никогда read-only — шаблон побеждает: как только шаблон описывает ключ, пометка перестаёт действовать. `Actionable()/Updates()/Prunes()` исключают Custom, поэтому custom-запись не даёт drift и физически не может быть применена.
- **Импорт — синхронизация.** `store.ImportDomains` в одной транзакции создаёт домены новых зон и удаляет домены зон, исчезнувших у провайдера (скоуп — `provider_account_id`). Пустой список зон не удаляет ничего: он неотличим от временной потери доступа, а удаление уносит домены вместе с историей чеков и пометками.
- Step 2: README
В разделе «Возможности» добавить описание секции Customs (пометить запись вне шаблона, вернуть обратно) и того, что переимпорт убирает домены удалённых зон.
- Step 3: Прогнать всё
Run: go build ./... && go test ./... && (cd web && npx tsc --noEmit && npm run test -- --run)
Expected: PASS (для internal/store нужен Docker).
- Step 4: Проверить, что плейсхолдер go:embed не тронут
Run: git status --short internal/web/dist/
Expected: пусто. Если index.html изменён — git checkout internal/web/dist/index.html.
- Step 5: Коммит и merge
git add CLAUDE.md README.md
git commit -m "docs: custom records and import-as-sync invariants"
git checkout main
git merge --no-ff feat/custom-records -m "Merge feat/custom-records: custom-записи в диффе и очистка исчезнувших зон"