Worklog for task "Merge next-js and app services"

22 сСнт. 2026 Π³., 10:19:05

Migration of the old website to a new engine and data model unification

The main work at this stage is not just migrating individual pages or resources, but essentially rebuilding the old website on the new haih-agent engine while finally abandoning the old data structure that historically evolved back in the MODX era.

The problem here is essentially the same as when updating other legacy websites: over time, a custom data structure, numerous templates, TV fields, special resource types, and application logic tied to the quirks of the old engine have accumulated around the original CMS. Therefore, a standard application "update" is not enough β€” the new website needs an architectural migration that preserves existing content while getting rid of old technical limitations.

What the old version had

Originally, the website ran on MODX, and a significant portion of the database structure still inherited that model. Different semantic entities existed separately from one another and often had their own templates and sets of TV fields.

Specifically, the following existed separately:

  • cities;
  • companies;
  • bathhouses and saunas;
  • reviews;
  • articles and publications;
  • regular resources/pages;
  • blogs and other content types.

In other words, the distinction between objects was fixed not only at the business-logic level, but also at the storage-structure level: different entities, different templates, different fields, and separate handling logic. For MODX, this was a natural way to organize a website, but as the new application develops, such a scheme only complicates maintenance and forces old limitations into the new architecture.

Transition to KbConcept

Now the website is finally moving away from the legacy database and the old entity model. Importers that transfer records from the old database to the new one have already been written and are running for all main data types.

The key architectural change is that practically all content is now reduced to a single base entity β€” KbConcept. The semantic distinction between objects is determined not by a separate table or model class, but by the type field.

For example:

  • city:default β€” city;
  • company:default β€” company;
  • resource:default β€” regular web page;
  • review:company β€” company review;
  • blog:default β€” public blog;
  • blog:personal β€” personal blog;
  • topic:default β€” publication.

Thus, instead of a large set of historically grown entities, we get a unified data model with a clear type system. This significantly simplifies the GraphQL schema, the frontend, component reuse, queries, data imports, and further website development.

At the same time, unification does not mean a loss of typing. On the contrary, TypeScript allows describing specific subtypes quite strictly on top of the general KbConcept and working with them safely in application code.

Typing KbConcept via template literal types

TypeScript's ability to use template literal types came in particularly handy here. Currently, the type code looks like this:

import { EnumValueConfigMap, SchemaTypes } from '@pothos/core'
import { KbConceptFragment } from 'src/gql/generated'

export const CustomKbConceptType = {
  City: {
    value: 'city:default',
    description: 'City',
  },
  Company: {
    value: 'company:default',
    description: 'Company',
  },
  ResourceDefault: {
    value: 'resource:default',
    description: 'Web page',
  },
  ReviewCompany: {
    value: 'review:company',
    description: 'Company review',
  },
  BlogDefault: {
    value: 'blog:default',
    description: 'Public blog',
  },
  BlogPersonal: {
    value: 'blog:personal',
    description: 'Personal blog',
  },
  TopicDefault: {
    value: 'topic:default',
    description: 'Publication',
  },
} 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
}

There are several particularly useful points here.

as const satisfies ...

The construction:

} as const satisfies EnumValueConfigMap<SchemaTypes>

solves two tasks at once.

as const prevents TypeScript from widening values like 'city:default' to a general string type. As a result, specific strings are preserved as literal types. For example, CustomKbConceptType.ReviewCompany.value has the exact type 'review:company' rather than just string.

Meanwhile, satisfies EnumValueConfigMap<SchemaTypes> checks that the entire object conforms to the contract expected by Pothos, without destroying the exact information about literal values inside the object. This provides a convenient combination of strict structure checking and highly precise type inference.

Template literal types

The most interesting part:

type: `company:${string}`

and similarly:

type: `city:${string}`

This makes it possible to express the design principle of KbConcept.type at the type system level: an object is considered a company not just for one specific value of company:default, but for any type within the company:* namespace.

For example, if company:premium, company:branch, or other specialized variants appear in the future, the Company type will already be able to describe them without manually creating a separate union.

Thus, the type naming convention of the form:

<group>:<subtype>

becomes not just a string convention in the database, but part of the application's static typing.

Type guards

Functions of the form:

export function isCompany(concept: KbConceptFragment): concept is Company

are custom type guards. After checking isCompany(concept), TypeScript already knows that inside the corresponding branch, concept.type has the form company:${string}.

The same approach is used for cities and reviews.

isMapItemCompany is particularly useful: it not only checks for the company: prefix, but also narrows the object down to a type where the lat and lng coordinates are guaranteed to be available as numbers. Thanks to this, subsequent map code no longer works with a "potentially a company with potentially coordinates", but with a properly typed map object.

For precise specialized variants, an even stricter check can be used:

type: typeof CustomKbConceptType.ReviewCompany.value

Here, the ReviewCompany type is tied directly to the value from the central CustomKbConceptType object. If the string value of the type is changed there, the type does not need to be manually duplicated in multiple places.

As a result, the general KbConcept entity does not turn the application into a set of untyped objects. On the contrary, thanks to the type convention, template literal types, and type guards, it is possible to maintain the convenience of a unified data model while achieving strict typing for specific scenarios on the frontend.

Importing old data

At present, importers for old entities have already been written and are operational. Main data has been imported, including resources, companies, and related content types.

This is an important milestone in the context of a complete migration: the new version of the website should no longer continue reading the old MODX database as the primary data source. Old entities are converted into the new unified model, and from then on, the application works with the new database and KbConcept.

Thus, the task gradually ceases to be a "new interface on top of the old website" and becomes a full-fledged migration to a new platform.

Map

The map displaying companies has also been migrated. Marker clustering functionality is preserved: when there are many objects, closely positioned points are merged into clusters, and when zooming in, they expand into individual elements.

The map uses the specialized MapItemCompany type so that after filtering at the TypeScript level, both the concept's belonging to company:* and the presence of coordinates are guaranteed.

Current result

At the moment:

  • the new website architecture is already built around KbConcept;
  • the main old entities no longer require separate models in the new application;
  • data importers from the old database have been written and are working;
  • resources, companies, and other main entities have been imported;
  • content types have been brought to a uniform <group>:<subtype> scheme;
  • type guards and strict typing for specific KbConcept varieties have been added on the frontend;
  • the map has been migrated;
  • object clustering on the map has been restored.

The next stage is to finalize the design and polish the visual part of the new website. After that, publication of the new version and the final transition to it are planned.

14.06.2026