Ворклог по задаче "Перенести сайт на haih-agent"
Перенос старого сайта на новый движок и унификация модели данных
Основная работа на этом этапе — не просто перенос отдельных страниц или ресурсов, а фактически пересборка старого сайта на новом движке haih-agent с окончательным отказом от старой структуры данных, которая исторически сложилась ещё во времена MODX.
Проблема здесь по сути такая же, как и при обновлении других старых сайтов: со временем вокруг первоначальной CMS накопилась собственная структура данных, множество шаблонов, TV-полей, специальных типов ресурсов и прикладной логики, завязанной на особенности старого движка. Поэтому обычного «обновления» приложения недостаточно — новый сайт нужно переносить архитектурно, сохраняя существующий контент, но избавляясь от старых технических ограничений.
Что было в старой версии
Изначально сайт работал на MODX, и значительная часть структуры базы данных до сих пор наследовала эту модель. Разные смысловые сущности существовали отдельно друг от друга и зачастую имели собственные шаблоны и наборы TV-полей.
В частности, отдельно существовали:
- города;
- компании;
- бани и сауны;
- отзывы;
- статьи и публикации;
- обычные ресурсы/страницы;
- блоги и другие типы контента.
То есть различие между объектами было зафиксировано не только на уровне бизнес-смысла, но и на уровне самой структуры хранения: разные сущности, разные шаблоны, разные поля и отдельная логика работы с ними. Для MODX это было естественным способом организации сайта, но при развитии нового приложения такая схема только усложняет поддержку и заставляет переносить старые ограничения в новую архитектуру.
Переход на KbConcept
Сейчас сайт окончательно уходит от старой базы и старой модели сущностей. Для всех основных типов данных уже написаны и работают импортёры, которые переносят записи из старой базы в новую.
Ключевое архитектурное изменение состоит в том, что практически весь контент теперь приводится к одной базовой сущности — KbConcept. Семантическое различие между объектами определяется не отдельной таблицей или отдельным классом модели, а полем type.
Например:
city:default— город;company:default— компания;resource:default— обычная веб-страница;review:company— отзыв о компании;blog:default— публичный блог;blog:personal— персональный блог;topic:default— публикация.
Таким образом, вместо большого набора исторически разросшихся сущностей получается единая модель данных с понятной системой типов. Это заметно упрощает GraphQL-схему, фронтенд, повторное использование компонентов, выборки, импорт данных и дальнейшее развитие сайта.
При этом унификация не означает потери типизации. Наоборот, TypeScript позволяет поверх общего KbConcept достаточно строго описать конкретные подтипы и безопасно работать с ними в прикладном коде.
Типизация KbConcept через template literal types
Особенно удачно здесь пригодилась возможность TypeScript использовать шаблонные строковые литералы в типах (template literal types). Сейчас код типов выглядит так:
import { EnumValueConfigMap, SchemaTypes } from '@pothos/core'
import { KbConceptFragment } from 'src/gql/generated'
export const CustomKbConceptType = {
City: {
value: 'city:default',
description: 'Город',
},
Company: {
value: 'company:default',
description: 'Компания',
},
ResourceDefault: {
value: 'resource:default',
description: 'Веб-страница',
},
ReviewCompany: {
value: 'review:company',
description: 'Отзыв о компании',
},
BlogDefault: {
value: 'blog:default',
description: 'Публичный блог',
},
BlogPersonal: {
value: 'blog:personal',
description: 'Персональный блог',
},
TopicDefault: {
value: 'topic:default',
description: 'Публикация',
},
} as const satisfies EnumValueConfigMap<SchemaTypes>
export type MapItemCompany = KbConceptFragment & {
type: `company:${string}`
lat: number
lng: number
}
export function isMapItemCompany(
concept: KbConceptFragment,
): concept is MapItemCompany {
return concept.type?.startsWith('company:') && concept.lat && concept.lng
? true
: false
}
export type Company = KbConceptFragment & {
type: `company:${string}`
}
export function isCompany(concept: KbConceptFragment): concept is Company {
return concept.type?.startsWith('company:') ? true : false
}
export type City = KbConceptFragment & {
type: `city:${string}`
}
export function isCity(concept: KbConceptFragment): concept is City {
return concept.type?.startsWith('city:') ? true : false
}
export type ReviewCompany = KbConceptFragment & {
type: typeof CustomKbConceptType.ReviewCompany.value
}
export function isReviewCompany(
concept: KbConceptFragment,
): concept is ReviewCompany {
return concept.type === CustomKbConceptType.ReviewCompany.value
}
Здесь есть несколько особенно полезных моментов.
as const satisfies ...
Конструкция:
} as const satisfies EnumValueConfigMap<SchemaTypes>
решает сразу две задачи.
as const не даёт TypeScript расширить значения вроде 'city:default' до общего типа string. В результате конкретные строки сохраняются как литеральные типы. Например, CustomKbConceptType.ReviewCompany.value имеет тип именно 'review:company', а не просто string.
При этом satisfies EnumValueConfigMap<SchemaTypes> проверяет, что весь объект соответствует контракту, ожидаемому Pothos, но не уничтожает точную информацию о литеральных значениях внутри объекта. Получается удобное сочетание строгой проверки структуры и максимально точного вывода типов.
Шаблонные литералы в типах
Самая интересная часть:
type: `company:${string}`
и аналогично:
type: `city:${string}`
Это позволяет выразить на уровне системы типов сам принцип устройства KbConcept.type: объект считается компанией не только при одном конкретном значении company:default, а при любом типе из пространства company:*.
Например, если в дальнейшем появятся company:premium, company:branch или другие специализированные варианты, тип Company уже сможет описывать их без создания отдельного union вручную.
То есть соглашение об именовании типов вида:
<группа>:<подтип>
становится не просто строковым соглашением в базе, а частью статической типизации приложения.
Type guards
Функции вида:
export function isCompany(concept: KbConceptFragment): concept is Company
являются пользовательскими type guard'ами. После проверки isCompany(concept) TypeScript уже знает, что внутри соответствующей ветки concept.type имеет форму company:${string}.
То же самое используется для городов и отзывов.
Отдельно полезен isMapItemCompany: он не только проверяет префикс company:, но и сужает объект до типа, в котором гарантированно доступны координаты lat и lng как числа. Благодаря этому код карты дальше работает не с «возможно компанией с возможно координатами», а уже с нормальным типизированным объектом карты.
Для точных специализированных вариантов можно использовать ещё более строгую проверку:
type: typeof CustomKbConceptType.ReviewCompany.value
Здесь тип ReviewCompany привязан непосредственно к значению из центрального объекта CustomKbConceptType. Если строковое значение типа будет изменено там, тип не придётся дублировать вручную в нескольких местах.
В итоге общая сущность KbConcept не превращает приложение в набор нетипизированных объектов. Наоборот, за счёт соглашения о type, template literal types и type guards получается сохранить удобство единой модели данных и одновременно получить строгую типизацию конкретных сценариев на фронтенде.
Импорт старых данных
На текущий момент импортёры старых сущностей уже написаны и работают. Импортированы основные данные, в том числе ресурсы, компании и связанные типы контента.
Это важный этап именно в контексте полного переезда: новая версия сайта уже не должна продолжать читать старую MODX-базу как основной источник данных. Старые сущности преобразуются в новую унифицированную модель и дальше приложение работает уже с новой базой и KbConcept.
Таким образом, задача постепенно перестаёт быть «новым интерфейсом поверх старого сайта» и становится полноценной миграцией на новую платформу.
Карта
Также перенесена карта с отображением компаний. Функциональность кластеризации маркеров сохранена: при большом количестве объектов близко расположенные точки объединяются в кластеры, а при изменении масштаба раскрываются в отдельные элементы.
Для карты как раз используется специализированный тип MapItemCompany, чтобы после фильтрации на уровне TypeScript были гарантированы и принадлежность концепта к company:*, и наличие координат.
Текущий результат
На данный момент:
- новая архитектура сайта уже строится вокруг
KbConcept; - основные старые сущности больше не требуют отдельных моделей в новом приложении;
- написаны и работают импортёры данных из старой базы;
- ресурсы, компании и другие основные сущности импортированы;
- типы контента приведены к единой схеме
<группа>:<подтип>; - на фронтенде добавлены type guards и строгая типизация конкретных разновидностей
KbConcept; - перенесена карта;
- восстановлена кластеризация объектов на карте.
Следующий этап — завершить оформление и довести визуальную часть нового сайта. После этого планируется публикация новой версии сайта и окончательный переход на неё.