---
title: "Итоговый проект: сервис заметок на pgx"
description: "Итоговый pgx проект на Go: сервис заметок с CRUD, поиском ILIKE, тегами-массивами, keyset-пагинацией, транзакцией pinned и таймаутами на каждый запрос."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: сервис заметок на pgx

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/25-itogovyj-proekt/*

Итоговый pgx проект собирает курс в один работающий сервис: заметки с полным CRUD, поиском по подстроке, тегами-массивами и двумя способами листания выдачи. Каждый кирпич уже разобран в предыдущих двадцати четырёх уроках, и новое здесь — только сборка и её цена: как делят ответственность cmd и internal, куда доезжает ошибка базы в ответе API и что происходит при получении SIGTERM. crud postgres go-скелет из этого урока переносится в свой проект почти без правок и доращивается по чек-листу. По ходу встретятся три живых места — счётчик на каждой странице, потерянный ctx.Err и nil вместо пустого массива в фильтре тегов.

Представьте запуск: внутри командного мессенджера появилась личная библиотека заметок — заголовок, тело, ярлыки вроде «работа» и «отпуск», флажок закрепить сверху, поиск по своему архиву. Фронт просит страницу списка с фильтрами и подгрузку следующей порции — классический crud postgres go-случай. Такой pgx проект собирается из курса целиком: пул из четвёртого урока, Exec и Query из пятого, CollectRows из седьмого, типы — uuid, text[], timestamptz — из десятого и двенадцатого, обёртка транзакции из четырнадцатого, дедлайны из пятнадцатого, PgError из шестнадцатого и семнадцатого. Нового в уроке мало, и это честно: на финале проверяется не знание свежих функций, а умение складывать знакомые в сервис, который не разваливается на границах слоёв. Порядок такой: схема и миграция, слои проекта, List с фильтрами и keyset, перевод ошибок в язык API, транзакция pinned, таймауты и выключение.

### Схема: заметки, теги и два индекса

Схема живёт в миграции 0001 — версионирование базы из двадцать первого урока, goose или golang-migrate, для проекта разницы нет. Колонки: id uuid с DEFAULT gen_random_uuid() — функция встроена в Postgres с тринадцатой версии; user_id uuid NOT NULL с внешним ключом на users — именно он поставит 23503, когда клиент пришлёт несуществующего пользователя; title и body — обычный text с NOT NULL и дефолтом у тела; tags text[] NOT NULL DEFAULT '{}' — короткий список ярлыков, который читается и пишется целиком вместе со строкой, как в двенадцатом уроке; pinned boolean NOT NULL DEFAULT false; updated_at timestamptz NOT NULL DEFAULT now() — каждая правка несёт updated_at = now() прямо в UPDATE, триггер здесь не обязателен. Индекса два. Btree по (user_id, updated_at DESC, id DESC) обслуживает и страницу списка, и keyset-условие — все три колонки запроса стоят в порядке индекса. GIN по tags разгружает пересечение массивов. Индекс pg_trgm для поиска оставлен в миграции закомментированным: включать его нужно осознанно, о цене разговора будет ниже.

### Миграция 0001: таблица и индексы

```go
-- 0001_notes.up.sql
CREATE TABLE notes (
    id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id    uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title      text NOT NULL,
    body       text NOT NULL DEFAULT '',
    tags       text[] NOT NULL DEFAULT '{}',
    pinned     boolean NOT NULL DEFAULT false,
    updated_at timestamptz NOT NULL DEFAULT now()
);

-- страница списка и keyset: пользователь, свежее сверху, тай-брейкер
CREATE INDEX notes_user_updated_idx
    ON notes (user_id, updated_at DESC, id DESC);

-- пересечение тегов
CREATE INDEX notes_tags_idx ON notes USING gin (tags);

-- включать, когда поиск стал горячим путём:
-- CREATE INDEX notes_title_trgm_idx ON notes USING gin (title gin_trgm_ops);

-- вниз: 0001_notes.down.sql — DROP TABLE notes;
```

Миграция создаёт таблицу и два индекса одним файлом: btree по (user_id, updated_at DESC, id DESC) под страницу и keyset, GIN по tags под пересечение массивов. gen_random_uuid() генерирует id на сервере, поэтому INSERT не требует uuid.New() в Go. pg_trgm-индекс оставлен закомментированным: решение о нём принимается по объёму данных, а не по привычке.

### Слои: models, repo, service

Структура сервиса — три файла в internal/notes и один в cmd. models.go держит структуру Note: ID и UserID типа uuid.UUID из github.com/google/uuid, Title и Body string, Tags []string, Pinned bool, UpdatedAt time.Time. pgx v5 переваривает google/uuid напрямую: под типом лежит [16]byte, а драйвер понимает типы-надстройки над массивом байт — механику pgtype разобрали в десятом уроке. repo.go — структура с *pgxpool.Pool и методами Create, List, Update, TogglePinned; репозиторий не знает про HTTP и принимает доменные параметры. service.go — правила: собирать like-паттерн, нормализовать теги, транслировать ошибки; хендлеры в main.go только переводят query-параметры и JSON в вызовы сервиса. N+1 в выводе тегов не случится по построению: теги едут в массиве внутри строки, одна заметка — одна строка результата, и CollectRows из седьмого урока собирает срез без второго запроса. Отдельная таблица note_tags дала бы JOIN или агрегацию — для коротких личных ярлыков это цена без пользы.

### List: фильтры, теги и keyset

List собирает три фильтра и курсор. Теги: выбран один ярлык — условие вида ярлык = ANY(tags) из двенадцатого урока; несколько — пересечение tags && $2::text[], заметка подходит, если делит с фильтром хоть один тег. В коде ниже живёт универсальная ветка ($2::text[] = '{}' OR tags && $2::text[]): пустой массив отключает фильтр, не меняя текст запроса. Ловушка ровно одна: nil-срез из Go уезжает SQL NULL, а NULL при сравнении с '{}' даёт не истину и не ложь, а NULL — вся OR-ветка молча рассыпается; поэтому репозиторий нормализует nil в []string{} до запроса. Поиск: title ILIKE $3 OR body ILIKE $3, паттерн собирается в сервисе как процент-подстрока. Честно про производительность: подстрока в процентах не оставляет btree шансов, без pg_trgm это последовательный скан по строкам пользователя — на десятках и сотнях заметок он дешевле любого индекса, а GIN по gin_trgm_ops включайте, когда заметок десятки тысяч и поиск стал горячим путём. Пагинация двойная: первые страницы ходят LIMIT/OFFSET-ом, кнопка «показать ещё» — keyset по кортежу (updated_at, id) последней строки; развёрнутая пара сравнений вместо row-конструктора держит план прозрачным, а тай-брейкер id обязателен: updated_at не уникален, без него состав страниц плывёт.

### List в репозитории: один запрос на страницу

```go
type ListCursor struct {
        UpdatedAt time.Time
        ID        uuid.UUID
}

type ListParams struct {
        UserID uuid.UUID
        Tags   []string // nil трактуем как «без фильтра»
        Query  string   // подстрока для ILIKE
        Cursor ListCursor
        Limit  int
}

func (r *Repo) List(ctx context.Context, p ListParams) ([]Note, bool, error) {
        if p.Tags == nil {
                p.Tags = []string{} // nil уехал бы NULL и сломал OR-ветку
        }
        if p.Limit <= 0 || p.Limit > 100 {
                p.Limit = 20
        }
        like := "%" + p.Query + "%"
        firstPage := p.Cursor.ID == uuid.Nil

        rows, err := r.pool.Query(ctx, `
                SELECT id, user_id, title, body, tags, pinned, updated_at
                FROM notes
                WHERE user_id = $1
                  AND ($2::text[] = '{}' OR tags && $2::text[])
                  AND (title ILIKE $3 OR body ILIKE $3)
                  AND ($4::bool OR updated_at < $5 OR (updated_at = $5 AND id < $6))
                ORDER BY updated_at DESC, id DESC
                LIMIT $7`,
                p.UserID, p.Tags, like, firstPage,
                p.Cursor.UpdatedAt, p.Cursor.ID, p.Limit+1)
        if err != nil {
                return nil, false, fmt.Errorf("notes list: %w", err)
        }
        notes, err := pgx.CollectRows(rows, pgx.RowToStructByName[Note])
        if err != nil {
                return nil, false, fmt.Errorf("collect notes: %w", err)
        }

        hasMore := len(notes) > p.Limit
        if hasMore {
                notes = notes[:p.Limit]
        }
        return notes, hasMore, nil
}
```

Нормализация nil в пустой срез спасает OR-ветку: NULL вместо пустого массива отключил бы теги молча. like-паттерн из процента и подстроки при пустом запросе совпадает со всеми строками. Флаг $4 снимает keyset на первой странице, дальше работает пара сравнений по индексу (user_id, updated_at DESC, id DESC). LIMIT с единицей запаса даёт строку-маркер: hasMore — превышение длины, хвост срезается. CollectRows закрывает курсор сам.

> **Живое место: COUNT(*) на каждой странице.** Страница «все заметки» начала отвечать за двести миллисекунд после десятка тысяч документов. Профиль показал: рядом с SELECT страницы каждое листание гоняло SELECT count(*) с теми же фильтрами ради надписи «всего N заметок» — и счётчик заново читал все совпавшие строки. Если счётчик нужен на каждой странице, то он дублирует самую дорогую часть запроса и растёт вместе с выборкой. Замена: has_more через LIMIT+1 — признак продолжения вместо числа; «всего N» переехало на отдельный редкий эндпоинт с кэшем на несколько секунд. Если бизнес требует точного числа в каждом листании, то это осознанное решение, и за него платят по счётчику трафика.

### Проверка: следующая страница keyset

**Вопрос:** Последняя заметка на экране: updated_at = 2025-05-01 10:00:00+00, id = 9f1c-... Порядок выдачи — ORDER BY updated_at DESC, id DESC. Какое условие в WHERE возьмёт следующую страницу?

- Только updated_at меньше значения последней строки
- updated_at не больше последней строки и id меньше последнего id
- ✅ Развёрнутая пара: updated_at меньше, либо равен последнему с id строго меньше
- OFFSET 20 — сдвиг на размер уже показанной страницы

Вариант «только меньше» теряет строки с равным updated_at и меньшим id; вариант «не больше и id меньше» отсекает заметки старее по времени с большим id. OFFSET читает и отбрасывает пропущенные строки — та же линейная цена, что у count.

### Что напечатает подсчёт has_more?

```go
// в выборке пользователя 6 заметок, все старее курсора
const perPage = 5

notes, hasMore, err := repo.List(ctx, notes.ListParams{
        UserID: uid, Cursor: cur, Limit: perPage,
})
// List делает LIMIT perPage + 1 и срезает хвост

fmt.Println(len(notes), hasMore)
```

**Что выведет программа?**

- ✅ 5 true — шестая строка стала маркером и была срезана
- 6 true — маркер остался в выдаче
- 5 false — has_more считается отдельным COUNT
- 6 false — LIMIT не сработал

LIMIT с единицей запаса приносит шестую строку-маркер: длина превысила лимит, hasMore стал истиной, хвост срезается до пяти. Клиент получает ровно страницу и честный признак продолжения без единого COUNT.

### Ошибки базы в язык API

Ошибки базы становятся ответами API в одном месте — функция translate в service.go. Шаг первый: errors.As распаковывает *pgconn.PgError, механика шестнадцатого урока; если As вернул false, это дорога, а не сервер, и наружу идёт 503 с ретраем на чтении. Код 23505 unique_violation: заголовок заметки уникален в рамках пользователя, констрейнт UNIQUE (user_id, title); ответ 409 и подсветка поля заголовка. Код 23503 foreign_key_violation: user_id, которого нет в users; ответ 422 с текстом «пользователь не найден» — валидация на границе всё равно стоит, а FK страхует гонку между проверкой и вставкой. pgx.ErrNoRows из QueryRow обновления — 404. Контекстные ошибки различаются errors.Is по context.DeadlineExceeded: истёкший дедлайн — 504 на чтении и 503 на записи. Правило языка API из семнадцатого урока: Detail и Message не покидают сервис — там имена таблиц и значения ключей; наружу идут свой код, своё сообщение, а имя констрейнта — только в лог.

### Проверка: чужой user_id

**Вопрос:** Клиент прислал POST /notes с user_id, которого нет в users; на колонке висит внешний ключ. Что произойдёт и как ответит API?

- Exec вернёт 23505: сервер посчитал это конфликтом уникальности, хендлер ответит 409
- Сервер молча вставит строку: заметки могут существовать без пользователя
- ✅ Exec вернёт 23503 foreign_key_violation; errors.As распакует PgError, хендлер ответит 422 с понятным текстом
- pgx вернёт обычную ошибку без кода SQLSTATE: классифицировать её нельзя

23503 — foreign_key_violation, серверная PgError с кодом: errors.As распаковывает её сквозь fmt.Errorf с %w. Хендлер переводит код в 422 с понятным текстом; 409 означает конфликт уникальности, а не отсутствующую ссылку.

### Транзакция pinned, таймауты и выключение

Инвариант заметок — закреплена максимум одна на пользователя. Переключение флага — две правки, и они обязаны жить или умирать вместе: pgx.BeginFunc из четырнадцатого урока открывает транзакцию, первый UPDATE ставит pinned = NOT pinned выбранной строке, второй снимает флаг со всех остальных заметок того же пользователя. Сбой второго откатывает первый: без транзакции между правками виден момент с двумя закреплёнными заметками, а конкурентные переключения усугубляют гонку. Собственная обёртка WithTx заменит BeginFunc, когда к каркасу добавится политика — метрика откатов или свой дедлайн на всю операцию. Таймауты из пятнадцатого урока: middleware ставит один context.WithTimeout на весь HTTP-запрос, вложенный дедлайн не может превысить родителя; для чтения бюджет — три секунды, для записи — две. Выключение по SIGTERM: сначала srv.Shutdown с дедлайном — сервер дописывает ответы и перестаёт принимать новые; затем pool.Close(). Деталь из исходников, которую легко проглядеть: Close пула не принимает контекст — в отличие от conn.Close(ctx) одиночного соединения — и блокируется, пока все коннекты не вернутся и не закроются; повторный вызов безвреден, внутри sync.Once.

### main.go: пул, HTTP и выключение

```go
func main() {
        ctx := context.Background()
        dsn := os.Getenv("DATABASE_URL") +
                "?pool_max_conns=8&pool_min_conns=2"

        pool, err := pgxpool.New(ctx, dsn)
        if err != nil {
                log.Fatal(err)
        }
        if err := pool.Ping(ctx); err != nil {
                log.Fatal(err)
        }

        svc := notes.NewService(pool)
        mux := http.NewServeMux()
        mux.Handle("GET /notes", httpTimeout(3*time.Second, svc.List))
        mux.Handle("POST /notes", httpTimeout(2*time.Second, svc.Create))
        mux.Handle("POST /notes/{id}/pin", httpTimeout(2*time.Second, svc.TogglePinned))

        srv := &http.Server{Addr: ":8080", Handler: mux}
        go func() {
                if err := srv.ListenAndServe(); err != http.ErrServerClosed {
                        log.Fatal(err)
                }
        }()

        stop := make(chan os.Signal, 1)
        signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
        <-stop

        shutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
        defer cancel()
        if err := srv.Shutdown(shutCtx); err != nil {
                log.Printf("выключение сервера: %v", err)
        }
        pool.Close() // без контекста: дождётся возврата всех коннектов
}

func httpTimeout(d time.Duration, next http.HandlerFunc) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                ctx, cancel := context.WithTimeout(r.Context(), d)
                defer cancel()
                next(w, r.WithContext(ctx))
        })
}
```

main владеет пулом и HTTP: pgxpool.New разбирает DSN с pool_max_conns и pool_min_conns, Ping на старте ловит недоступную базу там, где её видно. httpTimeout ставит дедлайн на каждый запрос — меньший из бюджетов выигрывает. По SIGTERM сначала srv.Shutdown дописывает ответы, затем pool.Close() без контекста дождётся возврата всех коннектов; порядок менять нельзя, закрытый пул при живом трафике даёт поток ошибок.

> **Живое место: потерянный ctx.Err.** Сервис ставил дедлайны в два слоя: middleware — три секунды на запрос, метод сервиса — свой WithTimeout на секунду с тем же именем ctx. При сбое он проверял errors.Is(err, context.DeadlineExceeded) и отвечал «база медленная» — хотя в половине случаев сгорал клиентский таймер, и честный ответ был 504, а не 503. Потерянный cancel от собственного WithTimeout ещё и копил таймеры до конца запроса. Правило: если дедлайны живут на двух слоях, то у каждого один владелец; свой слой различает сгоревший таймер по своему контексту — qCtx.Err() != nil означает свой дедлайн, иначе виноват родитель. Тогда 503 и 504 расходятся без гаданий, а лог называет виновника.

### Маршруты сервиса и их ошибки

| Маршрут | Метод | Ошибки и статусы |
| --- | --- | --- |
| GET /notes?tag=&q=&after= | repo.List | 200; пустая страница — пустой массив, не 404; after — кортеж курсора |
| POST /notes | repo.Create | 23505 → 409, 23503 → 422, дорога без PgError → 503 |
| PATCH /notes/{id} | repo.Update | pgx.ErrNoRows → 404; updated_at обновляет now() в самом UPDATE |
| POST /notes/{id}/pin | service.TogglePinned через pgx.BeginFunc | обе правки атомарны: сбой второй откатывает первую |

### Чек-лист проекта перед выкаткой

- Индекс (user_id, updated_at DESC, id DESC) создан миграцией — страница и keyset ходят по нему.
- List запрашивает LIMIT с запасом и возвращает has_more: COUNT(*) в цикле страниц не живёт.
- nil-теги нормализованы в []string{} до запроса: NULL в фильтре отключил бы ветку молча.
- Каждая ошибка базы проходит errors.As: 404, 409, 422, 503 — Detail и Message не покидают сервис.
- Один дедлайн на слой: middleware ставит бюджет запроса, транзакция получает его целиком.
- Выключение: srv.Shutdown с дедлайном, затем pool.Close — не наоборот.
- Тесты на живой базе: keyset, гонка двух TogglePinned и маппинг ошибок проверяются с testcontainers из двадцать третьего урока.

### Соберите WHERE метода List

Восстановите четыре пропуска: пересечение тегов, регистронезависимый поиск, порядок и лимит страницы.

```go
WHERE user_id = $1
  AND ($2::text[] = '{}' OR tags ___ $2::text[])
  AND (title ___ $3 OR body ILIKE $3)
  AND ($4::bool OR updated_at < $5 OR (updated_at = $5 AND id < $6))
ORDER BY updated_at ___, id DESC
___ $7
```

Подсказка: Пересечение массивов даёт &&, регистронезависимость — ILIKE, свежее сверху — DESC, страницу режет LIMIT.

Правильные ответы в пропусках: `&&`, `ILIKE`, `DESC`, `LIMIT`

### Соберите TogglePinned

Расставьте строки метода сервиса: обёртка транзакции, правка флага, снятие чужих закреплений, финал.

Правильный порядок строк:

1. `func (s *Service) TogglePinned(ctx context.Context, userID, id uuid.UUID) error {`
2. `  return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {`
3. `    _, err := tx.Exec(ctx, "UPDATE notes SET pinned = NOT pinned WHERE id = $1 AND user_id = $2", id, userID)`
4. `    if err != nil {`
5. `      return err`
6. `    }`
7. `    _, err = tx.Exec(ctx, "UPDATE notes SET pinned = false WHERE user_id = $1 AND id <> $2", userID, id)`
8. `    return err`
9. `  })`
10. `}`

### Карточки: проект целиком

- **Keyset-пагинация** — Следующая страница задаётся кортежем (updated_at, id) последней строки: развёрнутая пара сравнений вместо OFFSET. Цена константная на любой глубине; тай-брейкер id обязателен, потому что updated_at не уникален.
- **has_more** — LIMIT запрашивается с единицей запаса: лишняя строка — признак продолжения, метод срезает хвост. COUNT(*) на каждую страницу не нужен; «всего N» — отдельный редкий запрос или кэш.
- **tags && $2::text[]** — Пересечение массивов: заметка подходит, если делит хоть один тег с фильтром. Работает по GIN-индексу; точное совпадение одного тега — вариант tag = ANY(tags). nil из Go превращайте в пустой срез до запроса.
- **23503** — foreign_key_violation: user_id, которого нет в users. errors.As распаковывает PgError, API отвечает 422 с понятным текстом; Detail наружу не едет — он содержит значения ключей.
- **ILIKE '%q%'** — Регистронезависимый поиск по подстроке; btree не помогает, без pg_trgm это последовательный скан. На десятках заметок пользователя — нормально; десятки тысяч — GIN по gin_trgm_ops и проверка плана.
- **pool.Close()** — Без контекста, в отличие от conn.Close(ctx): блокируется, пока все коннекты не вернутся в пул; повторный вызов безвреден внутри sync.Once. Порядок выключения: сначала srv.Shutdown, потом Close.

Скелет готов к доращиванию. Соседние темы курса ложатся на него напрямую: массивы-теги и их запросы — в [двенадцатом уроке](/courses/pgx/lessons/12-massivy/), каркас транзакции и передача tx в репозиторий — в [паттерне «с транзакцией»](/courses/pgx/lessons/14-tx-pattern/), расстановка дедлайнов — в [уроке про контекст и таймауты](/courses/pgx/lessons/15-kontekst-tajmauty/), тонкости маппинга 23505 на поля формы — в [уроке про уникальность](/courses/pgx/lessons/17-unikalnost-23505/). Дальше по списку курса: миграции goose из двадцать первого, EXPLAIN из двадцать второго — проверьте, что List ходит по notes_user_updated_idx, тесты с testcontainers из двадцать третьего, QueryTracer из двадцать четвёртого. Проверять сборку нужно на живой базе: поднимите Postgres в Docker, залейте пятьсот заметок через Batch из восемнадцатого урока и прогоните список с фильтрами и листанием — расхождения с ожиданием ловятся на малых объёмах мгновенно. Последнее правило финала: считать цену каждой страницы — привычка, а не разовое упражнение.

## Коротко: что запомнить

- Итоговый pgx проект — сервис заметок: слои main, service и repo, схема с uuid, text[] и timestamptz, два индекса.
- Фильтр тегов — tags && $2::text[] по GIN, точное совпадение одного — = ANY; nil нормализуется в пустой срез до запроса.
- Поиск ILIKE по подстроке без pg_trgm — последовательный скан: на личных архивах честно, на десятках тысяч — GIN по gin_trgm_ops.
- Пагинация: LIMIT/OFFSET для первых страниц, keyset по (updated_at, id) глубже; has_more через LIMIT+1 вместо COUNT(*).
- 23505 и 23503 распаковываются errors.As и становятся 409 и 422; pgx.ErrNoRows — 404, дедлайн — 504 или 503.
- TogglePinned держит инвариант одной закреплённой заметки внутри pgx.BeginFunc: сбой второй правки откатывает обе.
- Выключение: srv.Shutdown с дедлайном, затем pool.Close без контекста — он дождётся возврата всех коннектов.

## Домашнее задание

Соберите сервис по блокам урока: миграция с двумя индексами, модель Note, репозиторий с Create, List и TogglePinned, HTTP-хендлеры с middleware дедлайна. Наполните таблицу пятьюстами заметками через Batch из восемнадцатого урока и замерьте страницу листания с COUNT(*) против has_more — разницу запишите цифрами. Снимите транзакцию с TogglePinned, ударьте по эндпоинту двумя конкурентными запросами и поймайте момент с двумя закреплёнными заметками, затем верните каркас на место. Уберите cancel у WithTimeout и пропустите go vet — проверка lostcancel подскажет, где таймер живёт лишнее. В конце откройте EXPLAIN ANALYZE метода List и убедитесь, что план ходит по notes_user_updated_idx, а не по Seq Scan.

## Вопросы и ответы по уроку

### Почему выдача не пагинируется OFFSET-ом, если это проще?

OFFSET читает и отбрасывает пропущенные строки: глубина страницы дорожает линейно, а состав страниц при вставках плывёт между запросами. Keyset по (updated_at, id) стоит одинаково на любой глубине и опирается на индекс списка. Первая страница у обоих способов одинаковая — различие проявляется к десятому листанию, когда OFFSET уже гоняет сотни лишних строк.

### Когда подключать pg_trgm и что он даёт?

GIN-индекс с классом gin_trgm_ops режет строку на триграммы и делает ILIKE с подстрокой в процентах индексным. На персональных архивах в десятки и сотни заметок последовательный скан дешевле поддержки ещё одного индекса на записи. Подключайте, когда заметки измеряются десятками тысяч или поиск стал горячим путём, и сверяйте план через EXPLAIN.

### Что вернёт API, если клиент отправит теги строкой, а не массивом?

Хендлер переводит JSON в []string до репозитория: строка вместо массива — 422 ещё на границе. Правило то же, что у любого crud postgres go-приложения: типы приезжают из HTTP в домен уже правильными, база не должна угадывать. pgx, в свою очередь, кладёт []string в text[] без единой обёртки — как в двенадцатом уроке.
