Репозиторий и видимость: страницы, второй ключ сортировки, total

база≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Таблица из прошлой статьи умеет хранить строки, чинить индексы и отказывать дублям. Чтобы из неё вырос сервис, поверх нужен ещё один слой — тот, что решает, какие строки этому читателю вообще существуют. Правило в дневнике простое: опубликованное видно всем, черновик — только автору. Написать его несложно; сложно написать его один раз. Ниже оно сначала расползается по обработчикам и утекает через прямую ссылку, потом счётчик обещает страницы, которых нет, потом одна заметка попадает сразу на две страницы, а один знак из формы поиска выдаёт всю таблицу. Лечится всё это одним и тем же — местом, через которое читающие запросы ходят и мимо которого к строкам не проходят.

Разделы темы:·

Обработчиков, читающих заметки, в сервисе всегда больше одного. Список — первый, и условие видимости в нём написано. Второй — одна заметка по прямой ссылке /notes/2: его пишут отдельно, обычно позже, и правило в него переезжает не всегда. Таблица в примере та же, что в прошлой статье, со своими индексами; новое здесь — только то, что поверх неё.

Кто именно пришёл, сервис узнаёт не из запроса: присланному «кто» верить нельзя, личность достаётся из сессии или токена. В примерах ниже этот «кто» — обычная строка viewer, и весь вопрос в том, куда она попадает, а куда нет.

Прогноз: Боб просит список заметок, а следом открывает прямую ссылку на чужой черновик. Что он получит в каждом случае?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Список честно спрятал от Боба «План побега» — и тот же черновик приехал к нему целиком по прямой ссылке. Ни одна из двух функций не сломана: первая содержит условие видимости, вторая — нет, и обе делают ровно то, что написано.

Так выходит всегда, когда условие живёт не значением, а текстом, который помнят наизусть. Читающих запросов в сервисе не два: список, одна заметка, счётчик для номеров страниц, поиск, выгрузка в CSV, письмо с дайджестом. Условие нужно каждому из них, а вспоминают о нём там, где о нём думали.

Условие видимости — часть запроса, а не украшение списка. Запрос без него отвечает на другой вопрос: не «что видно этому читателю», а «что вообще есть в таблице».

Первое лекарство напрашивается: сделать условие значением. Предикат visible(viewer) — обычная функция (row: Note) => boolean, её передают в where, как передавали бы число. Значение нельзя «помнить наизусть»: его либо передали, либо нет, и второе видно в коде.

Теперь страницы. Читатель листает по две заметки — это limit и offset поверх условия. Но номера страниц рисует не список, а второй запрос: сколько всего строк. Его пишут рядом со списком, одной строкой, и ровно в этой строке условие теряется чаще всего.

Прогноз: в таблице шесть заметок, Бобу видны не все. Сколько страниц нарисует счётчик и что окажется на каждой?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Счётчик насчитал шесть и пообещал три страницы. На первой две заметки, на второй одна, третья пуста — и это при том, что сам список отработал правильно. Читатель видит поломку там, где её нет: он жмёт «3» и попадает в пустоту.

Пустая страница — ещё мягкий исход. Счётчик мимо условия рассказывает и лишнее: по разнице между «всего 6» и «видно 3» любой посчитает, сколько у Ани черновиков. Число — тоже данные, и утечка числа ничем не лучше утечки заголовка.

Счётчик — тот же читающий запрос, только вместо строк он отдаёт их количество. Условие в нём обязано быть тем же самым, иначе два запроса говорят о двух разных таблицах.

Страницы нарезаются не из воздуха: сначала порядок, потом limit и offset. Порядок в дневнике естественный — свежие сверху, ORDER BY created_at DESC. Загвоздка в том, что у пачки заметок, приехавших одним импортом, время создания одно на всех.

Строки с равным ключом движок вправе отдать в любом порядке — он обещал отсортировать по created_at, и он это сделал. Встроенная сортировка JavaScript устойчива, поэтому в модели расхождение приходится внести руками: вторая страница читается из того же набора строк, но в другом порядке. Настоящему движку руки не нужны — это видно в прогоне следом.

Прогноз: четыре заметки с одним временем, страницы по две. Какая заметка попадёт на обе страницы, какая не попадёт ни на одну — и что изменит второй ключ?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

С одним ключом «первая» показана дважды, а «третья» не показана вовсе: между страницами порядок сместился на одну строку, и вместе с ним сместилось окно offset. Со вторым ключом порядок полный — у любых двух строк есть чем их развести, — и обе страницы сходятся.

Теперь то же самое, но без модели: настоящая таблица, настоящий ORDER BY и правка заметки между двумя страницами.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Две страницы по две строки с одинаковым created_at: сначала порядок по одному ключу, потом по двум
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

const titles = ['первая', 'вторая', 'третья', 'четвёртая'];

const fill = async () => {
  await client.query('DROP TABLE IF EXISTS notes');
  await client.query(`CREATE TABLE notes (
    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title text NOT NULL,
    body text NOT NULL DEFAULT '',
    created_at timestamptz NOT NULL
  )`);
  // пачка приехала одним импортом, и время создания у всех одно
  for (const title of titles)
    await client.query('INSERT INTO notes (title, created_at) VALUES ($1, $2)', [title, '2026-09-20 09:00:00+03']);
};

const page = async (order, offset) => {
  // порядок параметром не подставить — это часть запроса, а не значение;
  // поэтому список порядков закрыт и живёт в коде, а не приходит снаружи
  const { rows } = await client.query(`SELECT title FROM notes ORDER BY ${order} LIMIT 2 OFFSET $1`, [offset]);
  return rows.map((row) => row.title);
};

for (const order of ['created_at DESC', 'created_at DESC, id DESC']) {
  await fill();
  const first = await page(order, 0);
  // между страницами читатель правит заметку: ключ сортировки правка не трогает
  await client.query("UPDATE notes SET body = 'дописала список' WHERE title = 'первая'");
  const second = await page(order, 2);
  const shown = [...first, ...second];
  console.log(`ORDER BY ${order}`);
  console.log('  страница 1:', first.join(' | '));
  console.log('  страница 2:', second.join(' | '));
  console.log('  дважды:', shown.filter((t, i) => shown.indexOf(t) !== i).join(' | ') || '(никого)');
  console.log('  ни разу:', titles.filter((t) => !shown.includes(t)).join(' | ') || '(никого)');
}

await client.end();
node order.mjs

Модель не соврала: настоящая база задвоила ту же заметку и потеряла ту же, а со вторым ключом обе страницы сошлись. Почему правка вообще двигает строку: UPDATE в Postgres не меняет строку на месте, а записывает её новую версию туда, где нашлось место, и помечает старую мёртвой; в маленькой таблице новая версия ложится после всех остальных. План запроса здесь — последовательный просмотр и сортировка, и порядок равных ключей сложился такой же, в каком строки лежат на диске. Обещания, что так будет всегда, нет — на то ключи и равные.

Отсюда правило: последний ключ сортировки обязан быть уникальным. Тогда порядок задан целиком и не зависит ни от того, что лежит на диске, ни от того, какой план выбрал движок. Уникальный столбец под рукой всегда один и тот же — первичный ключ.

Второй ключ закрывает равенство ключей, но не сдвиг: новая заметка встаёт наверх списка, всё съезжает на строку вниз, и «пропусти два» пропускает уже не то. Лечится это не сортировкой, а курсором: вместо «пропусти два» сервер получает «то, что идёт после вот этой строки» — условие вида (created_at, id) < ($1, $2) по тем же ключам, что в ORDER BY. Курсор вы уже крутили на телефоне: страницы, курсор и hasMore в цикле синхронизации. Как страницы по курсору выдаёт сервер и почему курсором становится номер, а не время правки, собирает глава 11.

Порядок без уникального последнего ключа — не порядок, а совпадение. Страницы, нарезанные по совпадению, повторяют одни строки и теряют другие.

Три ошибки, три лекарства — и все три об одном: читающий запрос не должен ходить к таблице сам. Соберём место, куда они ходят вместо этого. Внутри у него правило видимости, единственная дверь к строкам и четыре запроса поверх неё: количество, страница, поиск и одна заметка.

Интерфейс Store<T>, который вы писали в части про типы, — уменьшенная копия как раз такого класса: один контракт на все сущности, конкретный тип подставляется на месте. В части VI у этого класса появляется имя — репозиторий.

Типы, кстати, появляются ровно здесь. Строки нашей таблицы — безымянные наборы полей, движку всё равно, что в них лежит; вид заметки им придаёт объявление в примере. В настоящем стеке это делает схема, из которой выводятся типы строк, — это будет Drizzle.

Прогноз: Боб просит обе страницы, ищет «%» и открывает две заметки по прямым ссылкам — чужую опубликованную и чужой черновик. Что ответит каждый вызов?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Страниц две, а не три; заметки на них не повторяются; поиск по «%» вернул единственную заметку, в заголовке которой процент действительно есть. А чужой черновик по прямой ссылке ответил «нет такой» — не «нельзя», а именно «нет».

Разница между этими двумя ответами больше, чем кажется. «Нельзя» подтверждает, что строка существует, и перебором номеров у сервиса можно выспросить, сколько у кого заметок и когда они появились. «Нет такой» не подтверждает ничего. Почему на чужое отвечают так же, как на несуществующее, и где это правило ломается, разбирает статья про роли и владение.

Чего репозиторий не делает, стоит сказать прямо. Он не проверяет права на запись — видимость отвечает только на вопрос «читать». И он не замок: мимо него к таблице пройти можно, это договорённость, а не запрет. Настоящий Postgres умеет и замок — политики построчного доступа, — но они не отменяют условие в запросе, а повторяют его на своей стороне.

Одна дверь — не про красоту кода. Это место, куда приходят с вопросом «кто это видит» и получают один ответ, а не пять ответов из пяти обработчиков.

  • Условие видимости — часть каждого читающего запроса, а не свойство списка. Список, одна заметка, счётчик, поиск, выгрузка: запрос без условия отвечает на вопрос «что есть в таблице», и чужой черновик уезжает читателю целиком.
  • Счётчик для номеров страниц — тот же запрос с тем же условием. Счётчик мимо условия обещает страницы, которых нет, и заодно выдаёт число спрятанных строк.
  • Порядок строк с равным ключом ничем не обещан: правка записывает новую версию строки, та ложится после остальных, и окно offset съезжает — одна строка показывается дважды, другая не показывается вовсе. Последний ключ сортировки обязан быть уникальным.
  • Знаки % и _ внутри строки из формы — служебные знаки шаблона, а не текст. Параметр запроса закрывает внедрение SQL и не закрывает их; экранирование закрывает их вместе со знаком экранирования, а ESCAPE называет этот знак в самом запросе.
  • Репозиторий — единственная дверь к строкам: условие, порядок и экранирование живут в нём, а обработчики ходят через него. Это тот самый Store<T> из части про типы, только с настоящими строками за спиной.
  • Чужая строка отвечает так же, как несуществующая. «Нельзя» подтверждает, что строка есть, и перебором номеров из сервиса вынимают то, чего он не собирался рассказывать.

1/4Список заметок прячет чужие черновики, а обработчик /notes/:id отдаёт строку по номеру. Что увидит посторонний, открывший прямую ссылку на чужой черновик?

2/4Номера страниц рисует счётчик всех строк таблицы, а список отдаёт только видимые. Чем это кончится для читателя?

3/4Страницы нарезаны по ORDER BY created_at DESC, и у нескольких заметок время создания совпадает. Почему одна из них может попасть сразу на две страницы?

4/4Читатель набрал в поиске один знак процента, а сервис подставляет строку в шаблон LIKE как есть. Что вернёт поиск?

Поиск, который видит чужое

В репозитории есть единственная дверь к строкам — #rows, и счётчик со списком ходят через неё. Поиск написали позже и отдельно: он спрашивает таблицу сам. Проведите поиск через ту же дверь, ничего больше не меняя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дверь принимает дополнительное условие — отдайте ей условие поиска, а видимость она добавит сама.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки поиск по «Отчёт» находит две заметки вместо трёх: чужой черновик «Отчёт за апрель» пропадает из выдачи, как и из списка. Заметьте, что чинить пришлось не условие видимости — оно как было написано один раз, так и осталось, — а маршрут запроса к строкам.

Это и есть смысл единственной двери: новый читающий запрос получает видимость не потому, что о ней вспомнили, а потому, что другого пути к строкам у него нет.

Знак, который закрыли не весь

Экранирование в этом поиске уже есть: % и _ из строки читателя закрываются обратной косой. Одного знака в списке не хватает, и запрос «C:\» находит пустоту вместо заметки с этим путём. Допишите, чего не хватает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Закрывать нужно и сам знак, которым закрывают, — иначе он уезжает в шаблон служебным и закрывает собой следующий знак.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки «C:\» находит «Путь C:\temp», а два других запроса отвечают как прежде. Порядок замен при этом не важен ровно потому, что замена одна: replace идёт по строке слева направо и в уже подставленные знаки не возвращается. Две замены подряд — сначала процентов, потом косых — испортили бы результат первой.

Второй ключ, который не разводит

Второй ключ сортировки в этот репозиторий уже добавили — по заголовку. Заголовки у двух заметок совпадают, и страницы снова повторяют одну строку и теряют другую. Почините сортировку, не трогая остальное.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй ключ развёл только те строки, у которых заголовки разные. Последним ключом должен идти уникальный столбец — номер строки.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки страницы отвечают «дважды: (никого)» и «ни разу: (никого)»: у любых двух строк теперь есть чем их развести, и порядок задан целиком. Ключ по заголовку при этом можно оставить — он задаёт вид списка, а уникальность добирает номер.

Правило общее: сколько бы ключей ни было, последний обязан быть уникальным. В базе это почти всегда первичный ключ, и добавляют его в ORDER BY даже тогда, когда «и так всё видно».