Цель: поднять инфраструктуру Jalyk на бесплатных тарифах и подключить к ней свой сайт, который живёт в отдельном репозитории и тянет @jalyk/* из публичного npm. Деплоим по частям: сначала база и хранилище, потом api, потом платформа web, потом публикация пакетов, и только в конце — сам сайт-потребитель.
Архитектура в проде состоит из четырёх кусков, которые надо разместить: платформа apps/web (SSR на TanStack Start — аккаунты, проекты, API-ключи, OAuth), сервер контента apps/api (долгоживущий Effect-процесс с CRUD, SSE и ассетами), управляемый Postgres с расширениями pgvector и pg_trgm, и S3-совместимое объектное хранилище для ассетов. Пятый кусок — npm-пакеты, которые ставит сам сайт.
- Postgres: Neon (serverless, free, есть
pgvector;pg_trgmставитсяCREATE EXTENSION) — либо Supabase (free, Postgres + S3-совместимое Storage в одном тарифе, оба расширения есть из коробки). Рекомендация: Supabase, потому что закрывает сразу и БД, и хранилище ассетов одним аккаунтом. - Объектное хранилище: Cloudflare R2 (free 10 ГБ, S3 API) либо Supabase Storage. Если БД на Supabase — берём его же Storage и не плодим сервисы.
- apps/web (SSR): Vercel Hobby (free) — TanStack Start собирается под Nitro и едет на Vercel из коробки. Альтернатива — Netlify free или Cloudflare.
- apps/api (долгоживущий Node + SSE): НЕ serverless — нужен процесс, который держит соединение для SSE. Render (free web service, засыпает при простое) либо Fly.io. Рекомендация: Fly.io (нет принудительного засыпания в той же мере), запасной — Render.
- Заведён проект Supabase (ref
oudszegzoytgphexuhdh, eu-north-1). - Выбор окружения БД через
DB_TARGET(local/supabase) реализован как Effect-стратегия в packages/db/src/config.ts:DbConfig+ слоиDbConfigLocal/DbConfigSupabase. Строки Supabase —SUPABASE_DATABASE_URL(transaction pooler :6543, рантайм) иSUPABASE_DIRECT_URL(session pooler :5432) в корневом.env. - Схема накатана на Supabase. Prisma CLI (
db push/migrate) через пулер падает с P1017, а прямой хостdb.<ref>.supabase.co— только IPv6 (у машины его нет). Обход: DDL применяется драйверомpgчерез transaction pooler — командаpnpm --filter @jalyk/db run ddl:push(packages/db/scripts/apply-ddl.mjs, берётprisma/schema.sql). - Осталось: включить расширения
CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;(для поиска, позже). - На будущее:
ddl:push— init-only (валит на непустой базе, т.к.schema.sqlгенеритсяmigrate diff --from-empty). Для обновлений схемы на живой базе перейти на инкрементальныйprisma migrate diffот текущего состояния БД к схеме.
- В packages/core/src/storage.ts заглушка
YandexAssetStorageLiveзаменена наS3AssetStorageLiveчерез@aws-sdk/client-s3(write/read/remove по ключуprojectId/uuid,forcePathStyle: true). Конфиг читается Effect Config:JALYK_S3_ENDPOINT/REGION/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY(секрет черезConfig.redacted). - Драйвер выбирается
JALYK_STORAGE=local|s3(apps/api/src/config.ts, apps/api/src/server.ts). - Раздача байтов остаётся проксированием через api (handler
serveчитаетUint8Arrayи стримит) — бакет приватный, presigned-URL не нужны. - Supabase: бакет
vedro(приватный) создан, S3 Access Key выписан,JALYK_S3_*заполнены в.env(endpointhttps://oudszegzoytgphexuhdh.storage.supabase.co/storage/v1/s3, regioneu-north-1). Smoke-тест write→read→remove черезS3AssetStorageLiveпрошёл успешно.
- Прод-runtime: собрать слой с
DatabaseLive+ новым S3-AssetStorage(а не Local/Yandex-заглушкой). - CORS: разрешить origin будущего сайта и web-платформы (сейчас сервер локальный, политики нет) — проверить, что preflight и
X-Api-Key/Authorizationпроходят. - Энв на хостинге:
DATABASE_URL,BETTER_AUTH_SECRET(обязан совпадать с web),PORT, креды S3.startуже есть (tsx --env-file-if-exists). - Проверить, что выбранный хост держит SSE-поток
/projects/:id/eventsбез обрыва по таймауту; keepalive в коде есть (Stream.tick25с).
- Выбрать Nitro-preset под хост (Vercel/Netlify/Cloudflare) и проверить прод-билд
pnpm --filter @jalyk/web build+start. - Энв:
DATABASE_URL(та же прод-база),BETTER_AUTH_SECRET(== api),BETTER_AUTH_URL=https://<домен-платформы>, креды GitHub/Google. - OAuth: в настройках GitHub/Google прописать прод-callback
https://<домен>/api/auth/callback/{github,google}; отозвать и перевыпустить скомпрометированные легаси-секреты (лежали в git-истории, см. заметку про env/oauth). - Завести первый проект и выпустить read/write API-ключ для своего сайта.
- Публикуемый граф для сайта:
@jalyk/client,@jalyk/studio,@jalyk/schema, плюс их зависимости из workspace — как минимум@jalyk/ui,@jalyk/contract(проверить полный граф). - Сейчас к публикации готов только
@jalyk/studio(естьtsup+publishConfig). У@jalyk/client/@jalyk/schema/@jalyk/uiexportsсмотрит вsrc, стоитprivate: true, нет билда и.d.ts— добавить сборку (tsup/tsc),files,publishConfig, снятьprivate. - Заменить внутренние
workspace:*на реальные диапазоны версий при публикации (Changesets илиpnpm publish -rрешают это; выбрать инструмент версионирования). - Учесть Tailwind у
@jalyk/studio: потребителю нужен экспорт стилей (./styles.cssуже есть) и инструкция по подключению пресета/слоёв. - React в peerDeps — проверить, что сайт даёт React 19.
Сейчас публикация ручная: pnpm run release патч-бампит все пять публичных пакетов, собирает и выкладывает, после чего в personal-jalyk вручную переписываются версии и делается pnpm install. Любой пропущенный шаг ломает деплой сайта на Vercel (ставится старое или несогласованное). Причина хрупкости — связка «два репозитория + ручная публикация + версии 0.0.x»: для semver ^0.0.x не расширяется даже на патч, поэтому потребитель обязан править каждую версию руками.
- Увести пакеты с
0.0.xна1.0.0(или хотя бы0.1.0), тогда^1.xвpersonal-jalykсам подхватывает патчи и миноры — после публикации достаточноpnpm update, без ручной правки версий. - Публикацию повесить на CI: GitHub Action на
main, который ставит зависимости, гоняетbuild:packagesи публикует. Каноничный инструмент — Changesets (@changesets/cli+changesets/action): changeset на изменение, бот сам бампит версии, ведёт changelog и публикует в npm при мердже. Тогда «забыть опубликовать» физически нельзя. - После публикации автоматически дёргать редеплой
personal-jalykчерез Vercel deploy hook, чтобы он пересобрался на свежих версиях (в связке с^1.x— полностью автоматический путь «запушил в jalyk → опубликовалось → сайт пересобрался»). - На хостинге прод-сборку запускать с
pnpm install --frozen-lockfile, чтобы рассинхрон лок-файла падал явно, а не собирал втихую не ту версию.
- Поставить
@jalyk/client(+@jalyk/schema) для чтения контента по API-ключу, направить на прод-URLapps/api. - Встроить
@jalyk/studio(админка) с тем же API и bearer/ключом; подключить стили. - Задеплоить сайт на free-хост (тот же Vercel/Netlify) с переменными: URL api и публичный read-ключ.
БД+расширения → S3-драйвер (раздел 2, блокер) → деплой api → деплой web и выпуск ключа → публикация пакетов → сайт.
- баг: в тулбаре в презенс - редактировать профиль - ничего не делает и не понятно как появлось
- переключение темы не работает в админке
- серверная часть рассогласования (api/web): на отдаче клиентскому запросу документ со структурно-сломанным полем (conformancePaths непуст) режется целиком, а безобидные неизвестные поля просто срезаются. Сейчас в студии всё видно и чинится, но сам срез на выдаче ещё не реализован.
- здоровье контента: перед тем как правка схемы (переименование/удаление поля, изменение predefined) уедет, показать в студии, сколько живых документов она уронит из клиентской выдачи (через conformancePaths по всем документам типа) — иначе массовый обвал контента видно не в студии, а по пропаже на сайте.
Ядро сделано (см. Архив). Не реализовано из решённого в дизайне:
- асинхронная валидация при восстановлении — сейчас путь обрезается только по структуре (неизвестный
key), но не проверяется, существует лиdocId/fieldPathв базе; нужна ступенчатая проверка с лоадерами и обрезкой до последнего реально валидного звена (смыкается с багами про здоровье контента ниже).
defineUnion который позволяет хранить случайное значение в поле из предопределенных. у всего поля появляется действие переключить поле. это поле-обертка по сути. внутри прописываются массив из полей которые доступны например defineUnion([defineString, defineNumber, defineReference]), также указывается дефолтное поле (первое в списке по дефолту или можно перезаписать). особенность в том, что имена полей внутри используются как дискриминаторы, поэтому они сохраняются вместе со значением в жсоне, чтобы при сохранении defineUnion знал какой компонент ему ренедрить внутри. я пока не могу придумать, в какой ситуации это будет полезно, поэтому прошу тебя помочь придумать ситуации для этого.
- пкм по превью документа - контекстно меню - дублировать, копировать, вставить.
Каждый сегмент навигации автоматически рисует хедер с названием (title из definePathSegment) и крестиком у правого края; крестик зовёт close уровня, у корневого его нет (canClose=false). Ширину и рамку колонки задаёт сам компонент вью, обёртка нейтральна (shrink-0), пропа стиля у definePathSegment нет. Поле ссылки через useSegmentNav/openDocument открывает сегмент документа: кнопка-карандаш редактирует выбранную цель, а меню «Новый документ» справа от поиска создаёт новую и сразу открывает её форму; дерево документ→документ рекурсивно, поэтому цепочка бесконечна. Реализация: packages/studio/src/data/navigation.tsx (SegmentHeader, useSegmentNav), packages/studio/src/fields/reference.tsx, рекурсия в views/segments.tsx.
У выбранного превью в поле ссылки две affordance: карандаш открывает документ в отдельном сегменте-редакторе (nav.go(openDocument)), а шеврон слева тоглит форму документа инлайн прямо под рядом поля. Форма берётся из конфига документа: добавлен variant='inline' в DocumentEditor (растёт по контенту, без h-full/скролла), удаление из неё чистит ссылку и сворачивает. Реализация: packages/studio/src/views/DocumentEditor.tsx, packages/studio/src/fields/reference.tsx.
Подписи и иконки кнопок поля-картинки приведены к набору: «Загрузить» (UploadIcon, всегда — заливка с диска), «Выбрать» (ImagesIcon, всегда — из библиотеки, бывшее «Смотреть все») и «Убрать» (XIcon, ghost, приглушённая, отодвинута через ml-auto, только при наличии обложки, бывшее «Удалить»). Слово «Заменить» убрано: кнопки-источники статичны по источнику, а не по состоянию. Корзину не используем — кнопка лишь отвязывает ссылку (handle.set(null)), файл остаётся в библиотеке, поэтому крестик честнее. Реализация: packages/studio/src/fields/image.tsx.
Линейный стек сегментов с персистом в localStorage и восстановлением после перезагрузки, как папки в ОС. Путь — одна линейная цепочка (на каждом уровне один открытый потомок); колонки и слои — два рендера одного стека. Сегменты объявляются статическим деревом definePathSegment (view + children) с типизированными переходами open.<child>(params) и PathSegmentLink. Путь хранится только в localStorage по ключу navigation.key (без URL); восстановление идёт по дереву определений, неизвестное звено в середине обрезает путь (структурно). Реализация: packages/studio/src/data/navigation.tsx, встроенное дерево views/segments.tsx, MillerView/LayerView переписаны поверх useNavStack, проп <Studio navigation>. Кастомный пример с кнопкой «Джарибек» (панель хранилища) — в apps/test-client.