Files
dns-autoresolver/docs/superpowers/plans/2026-08-19-custom-records-and-orphan-cleanup.md
T
vasyanskandClaude Opus 5 8f57a25af0 docs: implementation plan for custom records and orphan cleanup
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
2026-08-19 17:48:02 +07:00

58 KiB
Raw Blame History

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 с текстом провайдера; внутренние ошибки → generic internal 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.CustomActionable условие становится 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-записи в диффе и очистка исчезнувших зон"