Репозиторий и видимость: страницы, второй ключ сортировки, total
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Таблица из прошлой статьи умеет хранить строки, чинить индексы и отказывать дублям. Чтобы из неё вырос сервис, поверх нужен ещё один слой — тот, что решает, какие строки этому читателю вообще существуют. Правило в дневнике простое: опубликованное видно всем, черновик — только автору. Написать его несложно; сложно написать его один раз. Ниже оно сначала расползается по обработчикам и утекает через прямую ссылку, потом счётчик обещает страницы, которых нет, потом одна заметка попадает сразу на две страницы, а один знак из формы поиска выдаёт всю таблицу. Лечится всё это одним и тем же — местом, через которое читающие запросы ходят и мимо которого к строкам не проходят.
Обработчиков, читающих заметки, в сервисе всегда больше одного. Список — первый, и условие видимости в нём написано. Второй — одна заметка по прямой ссылке /notes/2: его пишут отдельно, обычно позже, и правило в него переезжает не всегда. Таблица в примере та же, что в прошлой статье, со своими индексами; новое здесь — только то, что поверх неё.
Кто именно пришёл, сервис узнаёт не из запроса: присланному «кто» верить нельзя, личность достаётся из сессии или токена. В примерах ниже этот «кто» — обычная строка viewer, и весь вопрос в том, куда она попадает, а куда нет.
Прогноз: Боб просит список заметок, а следом открывает прямую ссылку на чужой черновик. Что он получит в каждом случае?
Список честно спрятал от Боба «План побега» — и тот же черновик приехал к нему целиком по прямой ссылке. Ни одна из двух функций не сломана: первая содержит условие видимости, вторая — нет, и обе делают ровно то, что написано.
Так выходит всегда, когда условие живёт не значением, а текстом, который помнят наизусть. Читающих запросов в сервисе не два: список, одна заметка, счётчик для номеров страниц, поиск, выгрузка в CSV, письмо с дайджестом. Условие нужно каждому из них, а вспоминают о нём там, где о нём думали.
Условие видимости — часть запроса, а не украшение списка. Запрос без него отвечает на другой вопрос: не «что видно этому читателю», а «что вообще есть в таблице».
Первое лекарство напрашивается: сделать условие значением. Предикат visible(viewer) — обычная функция (row: Note) => boolean, её передают в where, как передавали бы число. Значение нельзя «помнить наизусть»: его либо передали, либо нет, и второе видно в коде.
Теперь страницы. Читатель листает по две заметки — это limit и offset поверх условия. Но номера страниц рисует не список, а второй запрос: сколько всего строк. Его пишут рядом со списком, одной строкой, и ровно в этой строке условие теряется чаще всего.
Прогноз: в таблице шесть заметок, Бобу видны не все. Сколько страниц нарисует счётчик и что окажется на каждой?
Счётчик насчитал шесть и пообещал три страницы. На первой две заметки, на второй одна, третья пуста — и это при том, что сам список отработал правильно. Читатель видит поломку там, где её нет: он жмёт «3» и попадает в пустоту.
Пустая страница — ещё мягкий исход. Счётчик мимо условия рассказывает и лишнее: по разнице между «всего 6» и «видно 3» любой посчитает, сколько у Ани черновиков. Число — тоже данные, и утечка числа ничем не лучше утечки заголовка.
Счётчик — тот же читающий запрос, только вместо строк он отдаёт их количество. Условие в нём обязано быть тем же самым, иначе два запроса говорят о двух разных таблицах.
Страницы нарезаются не из воздуха: сначала порядок, потом limit и offset. Порядок в дневнике естественный — свежие сверху, ORDER BY created_at DESC. Загвоздка в том, что у пачки заметок, приехавших одним импортом, время создания одно на всех.
Строки с равным ключом движок вправе отдать в любом порядке — он обещал отсортировать по created_at, и он это сделал. Встроенная сортировка JavaScript устойчива, поэтому в модели расхождение приходится внести руками: вторая страница читается из того же набора строк, но в другом порядке. Настоящему движку руки не нужны — это видно в прогоне следом.
Прогноз: четыре заметки с одним временем, страницы по две. Какая заметка попадёт на обе страницы, какая не попадёт ни на одну — и что изменит второй ключ?
С одним ключом «первая» показана дважды, а «третья» не показана вовсе: между страницами порядок сместился на одну строку, и вместе с ним сместилось окно offset. Со вторым ключом порядок полный — у любых двух строк есть чем их развести, — и обе страницы сходятся.
Теперь то же самое, но без модели: настоящая таблица, настоящий ORDER BY и правка заметки между двумя страницами.
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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app ORDER BY created_at DESC страница 1: первая | вторая страница 2: четвёртая | первая дважды: первая ни разу: третья ORDER BY created_at DESC, id DESC страница 1: четвёртая | третья страница 2: вторая | первая дважды: (никого) ни разу: (никого)
Модель не соврала: настоящая база задвоила ту же заметку и потеряла ту же, а со вторым ключом обе страницы сошлись. Почему правка вообще двигает строку: UPDATE в Postgres не меняет строку на месте, а записывает её новую версию туда, где нашлось место, и помечает старую мёртвой; в маленькой таблице новая версия ложится после всех остальных. План запроса здесь — последовательный просмотр и сортировка, и порядок равных ключей сложился такой же, в каком строки лежат на диске. Обещания, что так будет всегда, нет — на то ключи и равные.
Отсюда правило: последний ключ сортировки обязан быть уникальным. Тогда порядок задан целиком и не зависит ни от того, что лежит на диске, ни от того, какой план выбрал движок. Уникальный столбец под рукой всегда один и тот же — первичный ключ.
Второй ключ закрывает равенство ключей, но не сдвиг: новая заметка встаёт наверх списка, всё съезжает на строку вниз, и «пропусти два» пропускает уже не то. Лечится это не сортировкой, а курсором: вместо «пропусти два» сервер получает «то, что идёт после вот этой строки» — условие вида (created_at, id) < ($1, $2) по тем же ключам, что в ORDER BY. Курсор вы уже крутили на телефоне: страницы, курсор и hasMore в цикле синхронизации. Как страницы по курсору выдаёт сервер и почему курсором становится номер, а не время правки, собирает глава 11.
Порядок без уникального последнего ключа — не порядок, а совпадение. Страницы, нарезанные по совпадению, повторяют одни строки и теряют другие.
Поиск по заголовку — это LIKE с процентами по краям: «найди строки, внутри которых встречается вот это». Саму строку подставляют параметром, и внедрения SQL здесь нет: параметр приезжает значением, а не куском текста запроса. Но внутри значения знаки % и _ остаются служебными — их разбирает не разборщик SQL, а сам LIKE.
На границе строка уже проверена валидатором, и это не помогает: разбор отвечает на вопрос «строка ли это вообще и есть ли она», а не на вопрос «что она будет значить в шаблоне». Значение и смысл — разные вещи.
Соберём разбор шаблона в модели, ровно как его понимает база: % — любая строка, _ — ровно один знак, обратная косая снимает служебное значение со следующего знака.
Прогноз: четыре заметки и четыре запроса — «хлеб», «%», «_» и «C:\». Сколько найдёт каждый до экранирования и сколько после?
Запрос «%» нашёл всю таблицу, «_» — тоже всю: один знак «любая строка» и один знак «любой знак» в шаблоне с процентами по краям подходят к чему угодно. Запрос «C:\» не нашёл ничего: обратная косая закрыла процент, который шёл за ней, и поиск отправился искать заголовки, которые кончаются на «C:%». После экранирования каждый запрос нашёл ровно то, что человек набрал.
Та же четвёрка запросов в настоящей базе — и там служебные знаки те же самые.
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(); await client.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL )`); const titles = ['Купить хлеб', 'Скидка 50% в среду', 'Отчёт_квартал', 'Путь C:\\temp']; for (const title of titles) await client.query('INSERT INTO notes (title) VALUES ($1)', [title]); // первый вариант поиска: строка из формы едет в шаблон как есть const loose = (q) => client.query('SELECT title FROM notes WHERE title LIKE $1 ORDER BY id', [`%${q}%`]); // то же самое, но служебные знаки закрыты, а ESCAPE называет знак, которым закрыли const escapeLike = (q) => q.replace(/[\\%_]/g, (ch) => `\\${ch}`); const strict = (q) => client.query("SELECT title FROM notes WHERE title LIKE $1 ESCAPE '\\' ORDER BY id", [`%${escapeLike(q)}%`]); for (const q of ['хлеб', '%', '_', 'C:\\']) { const a = await loose(q); const b = await strict(q); console.log(`ищем ${JSON.stringify(q)}: как есть — ${a.rowCount}, экранированный — ${b.rowCount}`); console.log(' как есть:', a.rows.map((row) => row.title).join(' | ') || '(ничего)'); console.log(' экранированный:', b.rows.map((row) => row.title).join(' | ') || '(ничего)'); } await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app ищем "хлеб": как есть — 1, экранированный — 1 как есть: Купить хлеб экранированный: Купить хлеб ищем "%": как есть — 4, экранированный — 1 как есть: Купить хлеб | Скидка 50% в среду | Отчёт_квартал | Путь C:\temp экранированный: Скидка 50% в среду ищем "_": как есть — 4, экранированный — 1 как есть: Купить хлеб | Скидка 50% в среду | Отчёт_квартал | Путь C:\temp экранированный: Отчёт_квартал ищем "C:\\": как есть — 0, экранированный — 1 как есть: (ничего) экранированный: Путь C:\temp
Найденное сходится с моделью во всех четырёх запросах — до заголовков в каждой строке; сам вывод устроен иначе, прогон печатает ещё и число найденных строк. Новое здесь одно — ESCAPE: в SQL знак экранирования называют явно, потому что он часть шаблона, а не настройка соединения. Обратную косую Postgres понимает и без него, по умолчанию, но привычка писать ESCAPE дешёвая: знак виден в самом запросе, а не в умолчании движка.
Экранировать приходится и сам знак экранирования: закрой мы только процент с подчёркиванием, запрос «C:\» остался бы сломанным — его косая по-прежнему уезжала бы в шаблон служебной. Одна замена по трём знакам делает всё: обратная косая, процент и подчёркивание закрываются обратной косой.
И ещё одно, ради чего стоит знать, во что превращается поиск: шаблон, начинающийся с процента, обычному индексу не по зубам — движку придётся прочитать всю таблицу. Как увидеть это своими глазами в плане запроса, показывает статья о настоящем Postgres.
Экранируют не ради базы, а ради человека: он набрал знак, который для него ничего не значит, и вправе ожидать, что для поиска тот тоже ничего не значит.
Три ошибки, три лекарства — и все три об одном: читающий запрос не должен ходить к таблице сам. Соберём место, куда они ходят вместо этого. Внутри у него правило видимости, единственная дверь к строкам и четыре запроса поверх неё: количество, страница, поиск и одна заметка.
Интерфейс Store<T>, который вы писали в части про типы, — уменьшенная копия как раз такого класса: один контракт на все сущности, конкретный тип подставляется на месте. В части VI у этого класса появляется имя — репозиторий.
Типы, кстати, появляются ровно здесь. Строки нашей таблицы — безымянные наборы полей, движку всё равно, что в них лежит; вид заметки им придаёт объявление в примере. В настоящем стеке это делает схема, из которой выводятся типы строк, — это будет Drizzle.
Прогноз: Боб просит обе страницы, ищет «%» и открывает две заметки по прямым ссылкам — чужую опубликованную и чужой черновик. Что ответит каждый вызов?
Страниц две, а не три; заметки на них не повторяются; поиск по «%» вернул единственную заметку, в заголовке которой процент действительно есть. А чужой черновик по прямой ссылке ответил «нет такой» — не «нельзя», а именно «нет».
Разница между этими двумя ответами больше, чем кажется. «Нельзя» подтверждает, что строка существует, и перебором номеров у сервиса можно выспросить, сколько у кого заметок и когда они появились. «Нет такой» не подтверждает ничего. Почему на чужое отвечают так же, как на несуществующее, и где это правило ломается, разбирает статья про роли и владение.
Чего репозиторий не делает, стоит сказать прямо. Он не проверяет права на запись — видимость отвечает только на вопрос «читать». И он не замок: мимо него к таблице пройти можно, это договорённость, а не запрет. Настоящий Postgres умеет и замок — политики построчного доступа, — но они не отменяют условие в запросе, а повторяют его на своей стороне.
Одна дверь — не про красоту кода. Это место, куда приходят с вопросом «кто это видит» и получают один ответ, а не пять ответов из пяти обработчиков.
- Условие видимости — часть каждого читающего запроса, а не свойство списка. Список, одна заметка, счётчик, поиск, выгрузка: запрос без условия отвечает на вопрос «что есть в таблице», и чужой черновик уезжает читателю целиком.
- Счётчик для номеров страниц — тот же запрос с тем же условием. Счётчик мимо условия обещает страницы, которых нет, и заодно выдаёт число спрятанных строк.
- Порядок строк с равным ключом ничем не обещан: правка записывает новую версию строки, та ложится после остальных, и окно
offsetсъезжает — одна строка показывается дважды, другая не показывается вовсе. Последний ключ сортировки обязан быть уникальным. - Знаки
%и_внутри строки из формы — служебные знаки шаблона, а не текст. Параметр запроса закрывает внедрение SQL и не закрывает их; экранирование закрывает их вместе со знаком экранирования, аESCAPEназывает этот знак в самом запросе. - Репозиторий — единственная дверь к строкам: условие, порядок и экранирование живут в нём, а обработчики ходят через него. Это тот самый
Store<T>из части про типы, только с настоящими строками за спиной. - Чужая строка отвечает так же, как несуществующая. «Нельзя» подтверждает, что строка есть, и перебором номеров из сервиса вынимают то, чего он не собирался рассказывать.
1/4Список заметок прячет чужие черновики, а обработчик /notes/:id отдаёт строку по номеру. Что увидит посторонний, открывший прямую ссылку на чужой черновик?
2/4Номера страниц рисует счётчик всех строк таблицы, а список отдаёт только видимые. Чем это кончится для читателя?
3/4Страницы нарезаны по ORDER BY created_at DESC, и у нескольких заметок время создания совпадает. Почему одна из них может попасть сразу на две страницы?
4/4Читатель набрал в поиске один знак процента, а сервис подставляет строку в шаблон LIKE как есть. Что вернёт поиск?
Поиск, который видит чужое
В репозитории есть единственная дверь к строкам — #rows, и счётчик со списком ходят через неё. Поиск написали позже и отдельно: он спрашивает таблицу сам. Проведите поиск через ту же дверь, ничего больше не меняя.
Дверь принимает дополнительное условие — отдайте ей условие поиска, а видимость она добавит сама.
После правки поиск по «Отчёт» находит две заметки вместо трёх: чужой черновик «Отчёт за апрель» пропадает из выдачи, как и из списка. Заметьте, что чинить пришлось не условие видимости — оно как было написано один раз, так и осталось, — а маршрут запроса к строкам.
Это и есть смысл единственной двери: новый читающий запрос получает видимость не потому, что о ней вспомнили, а потому, что другого пути к строкам у него нет.
Знак, который закрыли не весь
Экранирование в этом поиске уже есть: % и _ из строки читателя закрываются обратной косой. Одного знака в списке не хватает, и запрос «C:\» находит пустоту вместо заметки с этим путём. Допишите, чего не хватает.
Закрывать нужно и сам знак, которым закрывают, — иначе он уезжает в шаблон служебным и закрывает собой следующий знак.
После правки «C:\» находит «Путь C:\temp», а два других запроса отвечают как прежде. Порядок замен при этом не важен ровно потому, что замена одна: replace идёт по строке слева направо и в уже подставленные знаки не возвращается. Две замены подряд — сначала процентов, потом косых — испортили бы результат первой.
Второй ключ, который не разводит
Второй ключ сортировки в этот репозиторий уже добавили — по заголовку. Заголовки у двух заметок совпадают, и страницы снова повторяют одну строку и теряют другую. Почините сортировку, не трогая остальное.
Второй ключ развёл только те строки, у которых заголовки разные. Последним ключом должен идти уникальный столбец — номер строки.
После правки страницы отвечают «дважды: (никого)» и «ни разу: (никого)»: у любых двух строк теперь есть чем их развести, и порядок задан целиком. Ключ по заголовку при этом можно оставить — он задаёт вид списка, а уникальность добирает номер.
Правило общее: сколько бы ключей ни было, последний обязан быть уникальным. В базе это почти всегда первичный ключ, и добавляют его в ORDER BY даже тогда, когда «и так всё видно».