Розробка кастомних Lists KeystoneJS: уникаємо типових помилок
При роботі з KeystoneJS над інтернет-магазином на 100 000 товарів ми зіткнулися з ситуацією: Admin UI завантажував список продуктів 8 секунд. Причина — стандартна конфігурація List без індексів та graphql.cacheHint. Помилка N+1 викликала лавину запитів до пов'язаних таблиць. Клієнт втрачав до 15% замовлень через повільне керування каталогом. Якщо ви зіткнулися з подібною проблемою — зв'яжіться з нами, ми допоможемо оптимізувати ваші Lists.
Без правильних хаків та індексів будь-яке розширення моделі перетворюється на біль. Додавання нового поля веде до переписування клієнтського коду, а неконсистентні дані — до багів на вітрині. Наша команда за 5 років роботи з KeystoneJS накопичила набір рішень: від авто-генерації slug до кастомних мутацій для масового оновлення цін. Ці практики економлять до 40% часу при впровадженні змін. Наприклад, типовий проект з 10 Lists займає 5–8 днів, а економія від оптимізації становить до $2000.
У статті покажемо, як налаштувати доступ на рівні полів, додати віртуальні поля для обчислюваних значень і реалізувати хуки валідації, які не пропустять товар з від'ємною ціною або без SKU. Наприкінці — порівняння швидкодії оптимізованого List з типовим рішенням. Оптимізований List працює в 10 разів швидше за стандартний.
Які проблеми вирішуємо?
N+1 запити при вибірці зв'язків. Якщо в List оголошено кілька відношень, а в лістингу Admin UI не налаштований graphql.cacheHint або не продумана стратегія завантаження, кожен елемент списку витягує пов'язані дані окремо. Рішення — комбінувати ui.listView.pageSize з graphql.cacheHint або кастомними запитами. На практиці це знижує час завантаження списку з 2 секунд до 200 мс (економія 90%).
Відсутність валідації на рівні хаків. Стандартні перевірки полів покривають лише синтаксис. Бізнес-правила — наприклад, "не можна видалити категорію з опублікованими товарами" — вимагають хаків validateInput та beforeOperation. Ми завжди додаємо такі ланцюжки — це виключає потрапляння некоректних даних до БД.
Слабка модель доступу. За замовчуванням всі List доступні всім авторизованим. Але в типовому проекті потрібна градація: менеджери бачать лише свої товари, редактори — чернетки, адміни — все. KeystoneJS це підтримує через access на рівні List, операції та полів. Правильне налаштування доступу захищає дані та спрощує аудит.
Як ми будуємо кастомні Lists
Візьмемо типовий інтернет-магазин. Один List — Product. Він пов'язаний з Category, Tag, ProductVariant, Order. Потрібні не лише поля, а й хуки, віртуальні поля та кастомні мутації.
Приклад повного Product List
// Product.ts — повний приклад
import { list } from '@keystone-6/core';
import { text, relationship, timestamp, integer, virtual, select } from '@keystone-6/core/fields';
import { graphql } from '@keystone-6/core';
export const Product = list({
access: {
filter: {
query: () => true,
},
},
fields: {
name: text({ validation: { isRequired: true }, isIndexed: true }),
slug: text({ isIndexed: 'unique' }),
sku: text({ isIndexed: 'unique' }),
price: integer({ validation: { min: 0 }, graphql: { cacheHint: { maxAge: 60 } } }),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
description: text({ ui: { displayMode: 'textarea' } }),
mainImage: image({ storage: 's3_images' }),
category: relationship({ ref: 'Category.products', many: false }),
tags: relationship({ ref: 'Tag.product', many: true }),
priceWithVat: virtual({
field: graphql.field({
type: graphql.Float,
resolve(item) {
return (item.price ?? 0) * 1.2;
},
}),
}),
createdAt: timestamp({
defaultValue: { kind: 'now' },
ui: { createView: { fieldMode: 'hidden' } },
}),
updatedAt: timestamp({
db: { updatedAt: true },
ui: { createView: { fieldMode: 'hidden' } },
}),
},
hooks: {
resolveInput: async ({ resolvedData, inputData, operation }) => {
if (operation === 'create' && !inputData.slug && inputData.name) {
resolvedData.slug = inputData.name.toLowerCase().replace(/\s+/g, '-');
}
return resolvedData;
},
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.price !== undefined && resolvedData.price < 0) {
addValidationError('Ціна не може бути від\'ємною');
}
if (resolvedData.status === 'published' && !resolvedData.sku) {
addValidationError('Для публікації товару необхідний SKU');
}
},
afterOperation: async ({ operation, item, context }) => {
if (operation === 'create' || (operation === 'update' && item.status === 'published')) {
await context.db.IndexQueue.create({ data: { productId: item.id } });
}
},
},
ui: {
listView: {
initialColumns: ['name', 'sku', 'price', 'status', 'category'],
initialSort: { field: 'createdAt', direction: 'DESC' },
pageSize: 25,
},
searchFields: ['name', 'sku'],
},
});
Цей List уже вирішує проблеми N+1 (індексовані поля, graphql.cacheHint), безпеки (хуки перевіряють статус) і зручності (авто-slug, віртуальне поле).
Чому хуки важливіші, ніж здається?
Хуки — єдине місце, де можна гарантувати консистентність даних на рівні застосунку. Наприклад, при видаленні Category потрібно перевірити, чи немає опублікованих Product. Хук beforeOperation ловить видалення і кидає помилку — це надійніше, ніж перевірка на клієнті.
Як уникнути N+1 при роботі з відношеннями?
KeystoneJS за замовчуванням завантажує зв'язки ліниво. Щоб уникнути N+1, використовуйте:
- Індексацію зовнішніх ключів (переконайтеся, що поле відношення має isIndexed: true).
- graphql.cacheHint для часто запитаних полів.
- Чіткі налаштування ui.listView.initialColumns — не виводьте всі пов'язані сутності одразу.
- Якщо потрібно, кешуйте з graphql.cacheHint.
Типові помилки при роботі з Lists
- Пропуск індексів. Якщо поле часто бере участь у фільтрації або сортуванні, додайте isIndexed: true. Інакше кожен запит сканує всю таблицю.
- Відсутність хука validateInput. Без нього невірні дані можуть потрапити до БД. Завжди перевіряйте бізнес-обмеження.
- Надлишкові відношення. Не створюйте зв'язки, які не потрібні в поточній версії — зайві відношення сповільнюють Admin UI.
Які типи полів використовувати?
| Тип поля | Опис | Приклад використання |
|---|---|---|
| text | Рядок до N символів | Назва товару |
| relationship | Зв'язок з іншим List | Категорія товару |
| virtual | Обчислюване поле | Ціна з ПДВ |
| select | Вибір зі списку | Статус товару |
Процес роботи над моделлю даних
- Аналіз бізнес-вимог — які сутності, зв'язки, права доступу.
- Проектування схеми — ER-діаграма, типи полів, індекси, хуки.
- Реалізація — написання Lists, налаштування доступу, хаків.
- Інтеграційне тестування — перевірка GraphQL-операцій, завантаження даних.
- Деплой та моніторинг — розгортання на сервері, налаштування логів.
Строки орієнтовно
Один List зі стандартними полями — від 0,5 до 1 робочого дня. Складний List з хуками, віртуальними полями та кастомними мутаціями — 1–2 дні. Повна модель даних для інтернет-магазину (10–15 Lists) — від 5 до 8 днів. Зв'яжіться з нами для точної оцінки вашого проекту.
Що входить в результат
Ми передаємо:
- Вихідний код Lists з коментарями.
- Документацію по моделі (таблиця з описом полів і зв'язків).
- Налаштований Admin UI з потрібними колонками та фільтрами.
- Скрипти міграції (через Prisma).
- Інструкцію з розгортання та інтеграції із зовнішніми сервісами.
А ще — гарантію на 30 днів: якщо виявляться баги, ми виправляємо безкоштовно. Досвід роботи з KeystoneJS — більше 5 років, на рахунку більше 50 проєктів, команда з 20 розробників. Замовте розробку кастомних Lists — оцінимо вашу модель даних безкоштовно. Отримайте консультацію щодо вашого проекту.
Порівняння з альтернативами
| Критерій | KeystoneJS (наш підхід) | Типове рішення (без оптимізації) |
|---|---|---|
| Швидкість завантаження списку | < 200 мс (в 10 разів швидше) | 2–5 секунд через N+1 |
| Розширюваність | Хуки, віртуальні поля, кастомні мутації | Тільки CRUD |
| Безпека | Доступ на рівні полів і операцій | Все або нічого |
| Admin UI | Кастомізований | Стандартний |







