Розробка кастомних 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 | Кастомізований | Стандартний |







