From 8f57a25af0b80677a349c801b1cd255bff88a689 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:48:02 +0700 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU --- ...08-19-custom-records-and-orphan-cleanup.md | 1643 +++++++++++++++++ 1 file changed, 1643 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-custom-records-and-orphan-cleanup.md diff --git a/docs/superpowers/plans/2026-08-19-custom-records-and-orphan-cleanup.md b/docs/superpowers/plans/2026-08-19-custom-records-and-orphan-cleanup.md new file mode 100644 index 0000000..f2c568d --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-custom-records-and-orphan-cleanup.md @@ -0,0 +1,1643 @@ +# 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`: + +```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`: + +```go + 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`). + +Добавить в конец файла: + +```go +// 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: Коммит** + +```bash +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): + +```go +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`: + +```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`: + +```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`: + +```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`): + +```go +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` рядом с доменными методами добавить: + +```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: Коммит** + +```bash +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` (использовать существующие фейки лоадера/провайдера из этого файла — не создавать новые): + +```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` добавить поле: + +```go +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()` заменить последнюю пару строк: + +```go + 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` дочитать ключи: + +```go + 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: Коммит** + +```bash +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` — добавить в мок две новые функции-поля): + +```go +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`: + +```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: + +```go + for _, d := range cs.Customs() { + resp.Customs = append(resp.Customs, toRecordView(d)) + } +``` + +- [ ] **Step 4: Хендлеры** + +В `internal/api/handlers.go` добавить (рядом с `handleApply`): + +```go +// 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` добавить: + +```go + // 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`: + +```go + r.Post("/customs", a.handleAddCustom) + r.Delete("/customs", a.handleDeleteCustom) +``` + +- [ ] **Step 6: Запустить тесты** + +Run: `go test ./internal/api/ -v` +Expected: PASS (включая существующие тесты чека/аплая). + +- [ ] **Step 7: Коммит** + +```bash +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`: + +```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`: + +```ts + addCustom: (projectId: string, id: string, key: string) => + req(projectPath(projectId, `/domains/${id}/customs`), { + method: "POST", + body: JSON.stringify({ key }), + }), + removeCustom: (projectId: string, id: string, key: string) => + req( + projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`), + { method: "DELETE" }, + ), +``` + +- [ ] **Step 3: Хуки** + +В `web/src/hooks/useApi.ts` после `useApplyDomain`: + +```ts +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: Коммит** + +```bash +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`): + +```tsx +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-*`: + +```css + --color-diff-custom: var(--diff-custom); +``` + +```css + --diff-custom: oklch(0.62 0.11 250); /* muted blue */ +``` + +Тёмная тема — тем же значением в соответствующем блоке (см. как продублирован `--diff-readonly`). + +- [ ] **Step 4: DiffView** + +В `web/src/components/DiffView.tsx`: + +```tsx +import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react" + +type Tone = "update" | "delete" | "custom" | "readonly" +``` + +В `TONE_META` добавить перед `readonly`: + +```tsx + 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`: + +```tsx + {action} +``` + +`Section` прокидывает действие построчно — добавить проп `renderAction?: (record: RecordView) => ReactNode` и передавать `action={renderAction?.(record)}` в `RecordRow`. Секция `custom` не селектируемая: в `selectable` условие становится + +```tsx + const selectable = + tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll +``` + +В `DiffView` добавить пропсы и секцию: + +```tsx + onMarkCustom, + onUnmarkCustom, +}: { + // ...существующие пропсы... + onMarkCustom?: (key: string) => void + onUnmarkCustom?: (key: string) => void + footerExtra?: ReactNode +}) { +``` + +```tsx +
( + + )) + } + /> +
( + + )) + } + /> +
+``` + +Импортировать `Button` из `@/components/ui/button`. + +- [ ] **Step 5: Страница** + +В `web/src/pages/DomainDiffPage.tsx`: + +```tsx +import { useAddCustom, useRemoveCustom } from "@/hooks/useApi" // добавить к существующему импорту хуков +``` + +```tsx + const addCustom = useAddCustom(id) + const removeCustom = useRemoveCustom(id) +``` + +и в разметку `DiffView`: + +```tsx + onMarkCustom={(key) => addCustom.mutate(key)} + onUnmarkCustom={(key) => removeCustom.mutate(key)} +``` + +Под блоком с ошибкой применения добавить вывод ошибок мутаций: + +```tsx + {(addCustom.isError || removeCustom.isError) && ( + + {(addCustom.error ?? removeCustom.error)?.message} + + )} +``` + +- [ ] **Step 6: Запустить тесты и типы** + +Run: `cd web && npx tsc --noEmit && npm run test -- --run` +Expected: PASS, TypeScript чист. + +- [ ] **Step 7: Коммит** + +```bash +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: Написать падающие тесты** + +```go +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` добавить: + +```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` выше по файлу): + +```go +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`, обновив доккоммент: + +```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` (существующий тест импорта, ожидающий массив, тоже нужно поправить под новый формат): + +```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`: + +```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` поправить строку интерфейса: + +```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`: + +```go + 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: Коммит** + +```bash +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`: + +```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`: + +```ts +export interface ImportResult { created: Domain[]; removed: Domain[] } +``` + +В `web/src/api/client.ts` заменить `importZones`: + +```ts + importZones: (projectId: string, accountId: string) => + req(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }), +``` + +и добавить `ImportResult` в список импортируемых типов в шапке файла. + +- [ ] **Step 4: Страница** + +В `web/src/pages/DomainsPage.tsx` после блока с ошибкой импорта: + +```tsx + {importZones.isSuccess && ( + + Создано {importZones.data.created.length}, удалено {importZones.data.removed.length} + + )} +``` + +- [ ] **Step 5: Запустить тесты и типы** + +Run: `cd web && npx tsc --noEmit && npm run test -- --run` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +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** + +В раздел «Инварианты (нарушать нельзя)» добавить два пункта: + +```markdown +- **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** + +```bash +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-записи в диффе и очистка исчезнувших зон" +```