# 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-записи в диффе и очистка исчезнувших зон" ```