Данные и синхронизация
Данные живут на устройстве и работают без всякого входа. Синхронизация между устройствами — отдельная возможность, которую включает вход через Telegram.
Три уровня, и все три необязательны сверху вниз
| Уровень | Что это | Без него |
|---|---|---|
| Устройство | IndexedDB браузера или вебвью: журнал операций, настройки, каталог навыков | приложение работает до конца сеанса в памяти |
| Журнал операций | история как список намерений, а не как готовые суммы | — это и есть источник истины |
| Сервер | Cloudflare Worker и база D1: тот же журнал, общий для ваших устройств | приложение полностью работоспособно, просто телефон и компьютер не видят друг друга |
Вход нужен только ради третьего уровня. Пока вы не вошли, приложение не знает о вас ничего: ни почты, ни имени, ни идентификатора — и ничего никуда не отправляет.
Почему история — журнал, а не готовые суммы
Раньше хранилось состояние: «в этот день у этого приоритета четыре блока». Два устройства, записавшие один и тот же месяц, приходилось сливать по ячейкам, и для счётчиков это делалось «берём большее». У такого правила была прямая цена: снятый блок возвращался, если его снимали на одном устройстве, а второе помнило старое число.
Теперь хранится намерение: «плюс блок», «минус блок». Из этого следует всё остальное:
- Сложение коммутативно. Порядок доставки не важен, и два нажатия на разных устройствах дают два блока, а не одно.
- Повтор безвреден. У операции есть свой идентификатор, вторая доставка той же операции ничего не меняет. Значит, отправлять можно как угодно и сколько угодно раз.
- Снятие — это слагаемое со знаком минус. Оно перестало быть особым случаем, а заодно появилось удаление перехода заряда и снятие отметки о достижении, которых в модели состояний не было вовсе.
Цена — объём: операций сильно больше, чем ячеек. Поэтому свёртка старых операций в месячные итоги не оптимизация, а условие жизнеспособности.
Чем помечена каждая операция
Гибридными логическими часами: физическое время задаёт общее направление, а порядок держит счётчик. Обычные стенные часы для этого не годятся — метка, взятая у телефона с неверной датой, навсегда выиграла бы у всех остальных или навсегда проиграла.
Как это синхронизируется
Сервер операции не сливает. Он хранилище и курсор: принимает пачку, отдаёт всё, что появилось после известного вам места. Складывает операции в состояние клиент — той же чистой функцией, которой пользуется локально. Иначе пришлось бы держать две реализации правил слияния и следить, чтобы они не разошлись.
Правил слияния три:
| Данные | Как разрешается совпадение |
|---|---|
| Клики по приоритетам и навыкам | складываются: +1 и −1 — обычные слагаемые |
| Переключения заряда | выигрывает операция с большей меткой: у минуты не бывает половины уровня |
| Достижения | то же — ячейка одна и неделима |
Обмен устроен так, чтобы отсутствие сети было обычным состоянием, а не аварией. Приложение пишет в локальный журнал и живёт дальше; движок при случае относит накопленное и приносит чужое. Не донёс — донесёт в следующий раз, ничего не потеряв: очередь отправки — это просто выборка неотмеченных операций из того же журнала, а не отдельный список, который может с ним разойтись.
Переход не стирает ничего и ни у кого
Первое подключение устройства к серверу не выбирает, чья история главнее. Считается разница, и наверх уходит только то, чего на сервере нет: счётчики — по большему, заряд — по свободной минуте, достижения — по отсутствующим.
Это не осторожность на всякий случай, а следствие потери данных на живом устройстве. Прежняя логика была «пусто на сервере — отправляем своё, не пусто — забираем чужое», и для устройства с многолетней историей «забрать чужое» означало не «догнать остальных», а «выбросить свои годы».
Что сервер делает со старым журналом
Операция весит около четверти килобайта, активный человек делает их десятки в день. Поэтому операции старше двух недель сервер заменяет их итогом — месячным снимком, из которого клиент строит ту же картину за одну строку вместо тысячи.
Два предохранителя, без которых свёртка теряла бы данные:
- Барьер по устройствам. Свернуть можно только до места, которое забрали все живые устройства, иначе отставший телефон никогда бы их не увидел. Устройство, не появлявшееся 90 дней, барьер держать перестаёт; когда оно вернётся, сервер отдаст ему снимки, и ничего не потеряется.
- Возраст. Свежее не сворачивается вовсе: выигрыш ничтожен, а поводов ошибиться много.
Правила слияния сервер берёт из того же модуля, что и приложение, а не повторяет своими словами.
Когда что записывается
| Что | Когда |
|---|---|
| Клики, заряд, клики навыков | пачкой, через 0,7 секунды после последнего изменения |
| Настройки, каталог навыков, достижения | сразу, каждый в своей очереди |
Почему клики — с задержкой. Пять тапов подряд иначе означали бы пять записей.
Почему настройки — сразу. Список приоритетов меняется редко, зато его потеря выглядит как «все данные пропали»: без него приложение открывается онбордингом.
Отложенная запись принудительно сбрасывается, когда приложение уходит в фон или закрывается. Запись на устройство и отправка на сервер — разные вещи: первая происходит всегда, вторая — когда есть сессия и сеть.
Глубина хранения
Ограничения по глубине больше нет. Оно существовало ради прежнего хранилища, где на одно значение давалось 4096 байт и месяцы приходилось подчищать за горизонтом в тринадцать месяцев. Журнал операций в этот предел не упирается, и старая история остаётся целиком.
На сервере глубину держит свёртка: подробность старых месяцев теряется, суммы — нет.
Наследство горизонта
У навыка есть поле «блоки из свёрнутых месяцев» — часы, которые прежнее хранилище успело свернуть до переезда. Они по-прежнему считаются в общем стаже и переоцениваются вместе со всеми остальными при смене цены клика. Новых таких блоков больше не появляется.
Что читается всегда
Каталог навыков и достижения читаются даже при выключенныхмодулях. Иначе выключенный и снова включённый модуль показал бы пустой список.
Ни то ни другое не привязано к дате и потому не попадает под «Стереть историю»: стартовый капитал навыка и отметки о жизни не выведены из кликов и не должны исчезать вместе с ними.
Стирание в журнале — это не удаление строк, а барьер: операция, которая говорит «история до меня не считается». Так стирание доезжает до второго устройства обычным обменом и не воскресает при следующем слиянии.
Что уходит на сервер, если вы вошли
Только то, что вы накликали, и идентификатор Telegram-аккаунта. Ни переписки, ни номера телефона, ни списка контактов приложение не запрашивает и получить не может: у мини-приложения такого доступа нет.
Токены продления лежат на сервере хешами — утечка базы не даёт войти. Предъявленный дважды токен считается признаком кражи и гасит всю цепочку устройства.
Где данные не сохранятся
- Демо. Хранилище подменено памятью, писать физически некуда; всё накликанное исчезнет. См. «Показать другу» и «Демо-режим».
- Разные адреса вне Telegram. В браузере хранилище привязано к домену: данные с превью-ссылки не переедут на боевой домен. Вход через Telegram это и решает — история приезжает с сервера.
- Браузер, который чистит хранилище. Приватный режим, «удалять данные сайтов при закрытии», агрессивная очистка на iOS. Пока вы не вошли, второй копии нет нигде, кроме этого устройства — на этот случай есть копия данных.
Как переехали старые данные
До журнала данные жили в CloudStorage Telegram: пары «ключ — строка», привязанные к паре «пользователь + бот», по 4096 байт на значение. Отсюда была и нарезка истории по месяцам, и двухсимвольные идентификаторы приоритетов.
Перенос делается один раз, целиком на устройстве, без сети и без сессии: прежнее хранилище читается, превращается в операции и больше не трогается никогда. Сервер к этому моменту отношения не имеет — он догоняет потом, обычным обменом.
Почему именно так, а не «переехал, когда ответил сервер»
Ровно на этой развилке данные и терялись. Устройство читало прежнее хранилище, писало в оба и переставало писать в первое только после удачного обмена, — то есть «переехал ли я» зависело от сети. Теперь развилки нет.
Прежние ключи перенос намеренно не стирает. Они остаются как есть, пока не переедут все устройства: перенос, который нечем перепроверить, — это перенос без права на ошибку.
Почему хранятся клики, а не часы
Так цена блока остаётся верной для всей истории: «клик = столько-то минут» — утверждение об учёте, а не о конкретном дне. Смена цены пересчитывает и прошлое, и это осознанное поведение.
Доли и заливка полос от цены не зависят вовсе — это отношения.
Рядом
Копия данных · Сброс · Пределы и лимиты · Настройки · Только в Telegram
Как это работает внутри
- Операция и правила слияния —
src/sync/ops.tsиsrc/sync/project.ts. Проекция чистая: на вход то, что уже прочитано, на выход — структуры, которыми пользуется экран. Поэтому правила проверяются таблицей примеров, без базы. - Метки —
src/sync/hlc.ts. Метка фиксированной ширины, поэтому сравнивается обычным строковым сравнением и хранится в SQL какtext. - Обмен —
src/sync/engine.ts: курсор «докуда прочитано», пачки по 500, до 20 страниц за заход. Ни одна ошибка здесь не всплывает исключением: синхронизация — удобство поверх работающего приложения. - Разница при переходе —
src/sync/fill.ts. Идемпотентна: второй прогон на том же состоянии не даёт ни одной операции, поэтому доливкой безопасно чинить и переход, и восстановление копии. - Разовый перенос —
src/sync/import.ts; прежняя схема, только чтение —src/store/legacy/persistence.ts. Каталогlegacy/— метка границы. - Локальное хранилище —
src/store/local/db.ts: IndexedDB, запасной путьlocalStorage, третий уровень — память. Отметка «данные переехали в IndexedDB» лежит именно вlocalStorage: иначе «базы нет, потому что человек здесь впервые» не отличить от «базы нет, потому что её не отдали». - Единственное место, знающее про сеть, —
src/sync/transport.ts. БезVITE_SYNC_URLсинхронизации нет вовсе, и приложение остаётся локальным. - Сервер —
worker/: маршруты вsrc/index.ts, обмен вsrc/sync.ts, свёртка вsrc/compact.ts, схема вmigrations/0001_init.sql.user_idберётся только из проверенного токена и никогда из тела запроса: в D1 нет row-level security, которая подстраховала бы от ошибки. - Журнал на сервере — таблица только на вставку; уникальный индекс по паре «пользователь + идентификатор операции» и делает доставку идемпотентной.