[{"data":1,"prerenderedAt":664},["ShallowReactive",2],{"navigation_docs_ru":3,"-ru-reference-tech-decisions":167,"-ru-reference-tech-decisions-surround":659},[4,61,101,146],{"title":5,"icon":6,"path":7,"stem":8,"children":9,"page":60},"Руководство","i-lucide-book-open","\u002Fru\u002Fguide","ru\u002F1.guide",[10,15,20,25,30,35,40,45,50,55],{"title":11,"path":12,"stem":13,"icon":14},"Введение","\u002Fru\u002Fguide\u002Fintroduction","ru\u002F1.guide\u002F01.introduction","i-lucide-house",{"title":16,"path":17,"stem":18,"icon":19},"Установка","\u002Fru\u002Fguide\u002Finstallation","ru\u002F1.guide\u002F02.installation","i-lucide-smartphone",{"title":21,"path":22,"stem":23,"icon":24},"Авторизация","\u002Fru\u002Fguide\u002Fauth","ru\u002F1.guide\u002F03.auth","i-lucide-lock",{"title":26,"path":27,"stem":28,"icon":29},"Кошельки","\u002Fru\u002Fguide\u002Fwallets","ru\u002F1.guide\u002F04.wallets","i-lucide-wallet",{"title":31,"path":32,"stem":33,"icon":34},"Категории","\u002Fru\u002Fguide\u002Fcategories","ru\u002F1.guide\u002F05.categories","i-lucide-tags",{"title":36,"path":37,"stem":38,"icon":39},"Транзакции","\u002Fru\u002Fguide\u002Ftransactions","ru\u002F1.guide\u002F06.transactions","i-lucide-receipt",{"title":41,"path":42,"stem":43,"icon":44},"Переводы","\u002Fru\u002Fguide\u002Ftransfers","ru\u002F1.guide\u002F07.transfers","i-lucide-arrow-left-right",{"title":46,"path":47,"stem":48,"icon":49},"Статистика","\u002Fru\u002Fguide\u002Fstatistics","ru\u002F1.guide\u002F08.statistics","i-lucide-bar-chart-3",{"title":51,"path":52,"stem":53,"icon":54},"Тема","\u002Fru\u002Fguide\u002Ftheme","ru\u002F1.guide\u002F09.theme","i-lucide-palette",{"title":56,"path":57,"stem":58,"icon":59},"Настройки","\u002Fru\u002Fguide\u002Fsettings","ru\u002F1.guide\u002F10.settings","i-lucide-settings",false,{"title":62,"icon":63,"path":64,"stem":65,"children":66,"page":60},"Разработка","i-lucide-code","\u002Fru\u002Fdevelopment","ru\u002F2.development",[67,71,76,81,86,91,96],{"title":16,"path":68,"stem":69,"icon":70},"\u002Fru\u002Fdevelopment\u002Finstallation","ru\u002F2.development\u002F01.installation","i-lucide-download",{"title":72,"path":73,"stem":74,"icon":75},"Граф кодовой базы","\u002Fru\u002Fdevelopment\u002Funderstand-anything","ru\u002F2.development\u002F02.understand-anything","i-lucide-network",{"title":77,"path":78,"stem":79,"icon":80},"Офлайн и PWA","\u002Fru\u002Fdevelopment\u002Foffline","ru\u002F2.development\u002F03.offline","i-lucide-wifi-off",{"title":82,"path":83,"stem":84,"icon":85},"История миграций данных","\u002Fru\u002Fdevelopment\u002Fmigration","ru\u002F2.development\u002F04.migration","i-lucide-database",{"title":87,"path":88,"stem":89,"icon":90},"Деплой","\u002Fru\u002Fdevelopment\u002Fdeployment","ru\u002F2.development\u002F05.deployment","i-lucide-rocket",{"title":92,"path":93,"stem":94,"icon":95},"Тестирование","\u002Fru\u002Fdevelopment\u002Ftesting","ru\u002F2.development\u002F06.testing","i-lucide-flask-conical",{"title":97,"path":98,"stem":99,"icon":100},"Дата-утилиты","\u002Fru\u002Fdevelopment\u002Fdate-utilities","ru\u002F2.development\u002F07.date-utilities","i-lucide-calendar",{"title":102,"icon":103,"path":104,"stem":105,"children":106,"page":60},"Справочник","i-lucide-file-code","\u002Fru\u002Freference","ru\u002F3.reference",[107,112,116,121,126,131,136,141],{"title":108,"path":109,"stem":110,"icon":111},"Архитектура","\u002Fru\u002Freference\u002Farchitecture","ru\u002F3.reference\u002F01.architecture","i-lucide-boxes",{"title":113,"path":114,"stem":115,"icon":44},"Типы транзакций","\u002Fru\u002Freference\u002Ftransaction-types","ru\u002F3.reference\u002F02.transaction-types",{"title":117,"path":118,"stem":119,"icon":120},"Синхронизация","\u002Fru\u002Freference\u002Fsync","ru\u002F3.reference\u002F03.sync","i-lucide-refresh-cw",{"title":122,"path":123,"stem":124,"icon":125},"Офлайн-first","\u002Fru\u002Freference\u002Foffline-first","ru\u002F3.reference\u002F04.offline-first","i-lucide-list-ordered",{"title":127,"path":128,"stem":129,"icon":130},"Тех. решения","\u002Fru\u002Freference\u002Ftech-decisions","ru\u002F3.reference\u002F05.tech-decisions","i-lucide-lightbulb",{"title":132,"path":133,"stem":134,"icon":135},"Валидация","\u002Fru\u002Freference\u002Fvalidation-strategy","ru\u002F3.reference\u002F06.validation-strategy","i-lucide-shield-check",{"title":137,"path":138,"stem":139,"icon":140},"Что изменилось со времён Firebase","\u002Fru\u002Freference\u002Ffirebase-migration","ru\u002F3.reference\u002F07.firebase-migration","i-lucide-hamburger",{"title":142,"path":143,"stem":144,"icon":145},"Производительность","\u002Fru\u002Freference\u002Fperformance","ru\u002F3.reference\u002F08.performance","i-lucide-gauge",{"title":147,"icon":148,"path":149,"stem":150,"children":151,"page":60},"Премиум","i-lucide-star","\u002Fru\u002Fpremium","ru\u002F4.premium",[152,157,162],{"title":153,"path":154,"stem":155,"icon":156},"Обзор","\u002Fru\u002Fpremium\u002Foverview","ru\u002F4.premium\u002F01.overview","i-lucide-layers",{"title":158,"path":159,"stem":160,"icon":161},"Telegram-бот","\u002Fru\u002Fpremium\u002Ftelegram-bot","ru\u002F4.premium\u002F02.telegram-bot","i-lucide-send",{"title":163,"path":164,"stem":165,"icon":166},"AI Chat","\u002Fru\u002Fpremium\u002Fai-chat","ru\u002F4.premium\u002F03.ai-chat","i-lucide-sparkles",{"id":168,"title":127,"body":169,"description":650,"extension":651,"links":652,"meta":653,"navigation":654,"path":128,"seo":655,"stem":129,"__hash__":658},"docs_ru\u002Fru\u002F3.reference\u002F05.tech-decisions.md",{"type":170,"value":171,"toc":629},"minimark",[172,176,181,189,200,214,228,232,251,257,266,273,284,295,299,306,319,325,329,344,363,371,383,389,412,416,430,436,440,447,456,460,474,499,503,513,519,526,536,547,551,554,559,563,577,604,608],[173,174,175],"p",{},"Осознанные технические решения и их обоснование.",[177,178,180],"h2",{"id":179},"powersync-offline-first-локальная-sqlite-как-источник-правды","PowerSync offline-first (локальная SQLite как источник правды)",[173,182,183,184,188],{},"Приложение использует PowerSync для репликации Supabase Postgres в локальную WASM SQLite базу данных на клиенте. Сторы читают данные исключительно из этой локальной SQLite через ",[185,186,187],"code",{},"watchTable('SELECT * FROM ...', [], cb)",".",[173,190,191,195,196,199],{},[192,193,194],"strong",{},"Почему локальная SQLite как источник правды:","\nЭто делает приложение полностью функциональным без подключения к сети. Чтение мгновенно (без round-trip). Одна подписка ",[185,197,198],{},"watchTable"," срабатывает сразу с текущими локальными строками И при каждом изменении - будь то локальная запись или входящая серверная синхронизация. Этот единственный механизм заменяет прежнее разделение между начальной загрузкой и realtime-подпиской.",[173,201,202,205,206,209,210,213],{},[192,203,204],{},"Почему PowerSync вместо кастомной офлайн-очереди:","\nВ прошлом использовалась написанная вручную офлайн-очередь (",[185,207,208],{},"components\u002Foffline\u002Freplay*",") с XOR-хеш обнаружением удалений, ремапингом локальных ID и пагинированным дельта-запросом. PowerSync делает всё это автоматически: поддерживает локальную CRUD-очередь, сливает её в Supabase через ",[185,211,212],{},"uploadData"," коннектора, согласует серверные изменения через logical replication. Никакого кода очереди, никакой логики несовпадения хешей, никакого ремапинга ID.",[173,215,216,219,220,223,224,227],{},[192,217,218],{},"Почему IDBBatchAtomicVFS (бэкенд IndexedDB) вместо OPFS:","\nOPFS (Origin Private File System) быстрее, но требует ",[185,221,222],{},"SharedArrayBuffer",", который требует заголовков cross-origin isolation (COOP\u002FCOEP). Эти заголовки ломают Google Fonts, Iconify CDN и другие сторонние ресурсы. ",[185,225,226],{},"IDBBatchAtomicVFS"," (на основе IndexedDB) не требует cross-origin isolation и работает в стандартном деплое.",[177,229,231],{"id":230},"оптимистичные-записи-с-откатом","Оптимистичные записи с откатом",[173,233,234,235,238,239,242,243,246,247,250],{},"Методы сторов немедленно обновляют ",[185,236,237],{},"items"," (оптимистично), затем вызывают ",[185,240,241],{},"upsertRow","\u002F",[185,244,245],{},"deleteRow"," для записи в локальную SQLite. Если локальная запись падает, оптимистичное изменение откатывается путём восстановления снимка ",[185,248,249],{},"prev",", и показывается тост с ошибкой.",[173,252,253,256],{},[192,254,255],{},"Почему оптимистично, а не с ожиданием:","\nОжидание записи в SQLite блокировало бы UI, даже несмотря на то что это локальная внутрипроцессная операция. Оптимистичные обновления дают мгновенную обратную связь. Путь отката обрабатывает редкий случай, когда сама локальная запись завершается ошибкой (например, конфликт транзакции).",[173,258,259,262,263,265],{},[192,260,261],{},"Замечание об отклонении сервером:"," серверные ошибки (нарушения RLS Postgres, исключения данных) выводятся отдельно - через обработчик upload-error в ",[185,264,212],{}," коннектора, а не через catch локальной записи.",[177,267,269,272],{"id":268},"shallowref-для-элементов-сторов",[185,270,271],{},"shallowRef"," для элементов сторов",[173,274,275,276,279,280,283],{},"Все Pinia-сторы используют ",[185,277,278],{},"shallowRef\u003CMap | null>"," вместо ",[185,281,282],{},"ref"," для коллекции элементов. Методы сторов создают новые ссылки на объекты при каждой мутации для запуска реактивности.",[173,285,286,289,291,292,294],{},[192,287,288],{},"Почему:",[185,290,282],{}," глубоко отслеживал бы каждое вложенное свойство потенциально тысяч объектов транзакций\u002Fкошельков\u002Fкатегорий. ",[185,293,271],{}," отслеживает только ссылку верхнего уровня. Явный паттерн «создать новый объект» при записи - осознанный компромисс: чуть более многословные сторы, значительно меньшие накладные расходы реактивности.",[177,296,298],{"id":297},"клиентские-uuid-без-ремапинга","Клиентские UUID (без ремапинга)",[173,300,301,302,305],{},"Каждая новая сущность получает UUID, сгенерированный на клиенте (",[185,303,304],{},"crypto.randomUUID()"," или аналог) до локальной записи. Этот UUID является постоянным ID - клиент владеет им весь офлайн-цикл, и он никогда не переназначается.",[173,307,308,310,311,314,315,318],{},[192,309,288],{},"\nPowerSync ставит запись в очередь локально и загружает её позже, поэтому ID должен быть стабильным ещё до того, как строку увидит сервер. Схема Supabase использует ",[185,312,313],{},"text"," PK с ",[185,316,317],{},"default gen_random_uuid()::text",", поэтому любой UUID, сгенерированный клиентом, принимается как есть. Нет обращения к серверу за выдачей ID, нет ремапинга, нет каскадных обновлений строк, ссылающихся на новую сущность.",[173,320,321,324],{},[192,322,323],{},"Почему нет FK-ограничений в Postgres:","\nПорядок загрузки PowerSync не гарантирован. Транзакция, ссылающаяся на кошелёк, может быть загружена раньше самого кошелька. FK-ограничения отклонили бы валидные данные. Ссылочная целостность обеспечивается логикой приложения.",[177,326,328],{"id":327},"sqlite-views-требуют-insertupdate-а-не-upsert","SQLite views требуют INSERT\u002FUPDATE, а не upsert",[173,330,331,332,335,336,339,340,343],{},"Клиентские таблицы PowerSync - это SQLite views с INSTEAD OF триггерами. Они принимают обычные ",[185,333,334],{},"INSERT"," и ",[185,337,338],{},"UPDATE",", но НЕ поддерживают ",[185,341,342],{},"INSERT ... ON CONFLICT"," (синтаксис upsert).",[173,345,346,352,353,355,356,358,359,362],{},[192,347,348,349,351],{},"Как ",[185,350,241],{}," это решает:","\nВыполняет проверку существования, затем либо ",[185,354,334],{}," (новая строка), либо ",[185,357,338],{}," (существующая), обёрнутые в ",[185,360,361],{},"writeTransaction"," для атомарности. Вызывающий всегда владеет ID, поэтому INSERT\u002FUPDATE однозначны.",[177,364,366,367,370],{"id":365},"троттлинг-reconciletrns-и-подавление-изменений","Троттлинг ",[185,368,369],{},"reconcileTrns"," и подавление изменений",[173,372,373,376,377,379,380,382],{},[185,374,375],{},"useTrnsStore"," использует 120ms окно троттлинга для своего ",[185,378,198],{}," (против 30ms по умолчанию для других таблиц). Также используется ",[185,381,369],{}," для подавления «эха» собственной оптимистичной записи.",[173,384,385,388],{},[192,386,387],{},"Почему большее окно троттлинга:","\nТранзакции - самая большая таблица. Во время начальной синхронизации PowerSync генерирует много быстрых событий изменения строк. 120ms trailing coalesce сворачивает всплеск в один перезапрос, снижая нагрузку на CPU.",[173,390,391,397,398,400,401,404,405,407,408,411],{},[192,392,393,394,396],{},"Почему ",[185,395,369],{},":","\nПосле оптимистичной записи watch снова срабатывает с теми же данными (эхо записи). ",[185,399,369],{}," обнаруживает, что ничего не изменилось (сравнивая по ",[185,402,403],{},"updatedAt","), и возвращает тот же ",[185,406,249],{}," ref без изменений, подавляя лишний цикл реактивности. Также переиспользует неизменённые объекты строк, так что ",[185,409,410],{},"rowToTrn"," выполняется только для дельты.",[177,413,415],{"id":414},"очистка-перед-подключением-другого-пользователя-fail-closed","Очистка перед подключением другого пользователя (fail-closed)",[173,417,418,421,422,425,426,429],{},[185,419,420],{},"connectPowerSync"," проверяет хранящийся ",[185,423,424],{},"finapp.psDbOwnerUid"," (localStorage). Если локальная SQLite принадлежит другому пользователю, вызывается ",[185,427,428],{},"disconnectAndClear()"," перед подключением - сначала очищается локальная база данных.",[173,431,432,435],{},[192,433,434],{},"Почему fail-closed:","\nЕсли сама очистка завершается ошибкой, маркер владельца сохраняется. Следующая сессия другого пользователя всё равно попытается очиститься перед чтением данных. Это предотвращает утечку данных пользователя A к пользователю B на общем устройстве.",[177,437,439],{"id":438},"жёсткая-навигация-при-выходе","Жёсткая навигация при выходе",[173,441,442,443,446],{},"Выход из системы использует ",[185,444,445],{},"window.location.href = '\u002Flogin'"," вместо навигации Vue Router.",[173,448,449,451,452,455],{},[192,450,288],{},"\nЖёсткая навигация уничтожает всё JavaScript-состояние - Pinia-сторы, WebSocket-соединения, реактивные наблюдатели, кешированные данные. Это предотвращает утечку устаревшего состояния авторизации в следующую сессию. ",[185,453,454],{},"navigateTo()"," Vue Router сохранил бы всё состояние в памяти.",[177,457,459],{"id":458},"безопасный-арифметический-парсер-в-калькуляторе","Безопасный арифметический парсер в калькуляторе",[173,461,462,463,466,467,470,471,188],{},"Калькулятор формы транзакции использует рекурсивный нисходящий парсер (",[185,464,465],{},"app\u002Fcomponents\u002FtrnForm\u002Futils\u002Fcalculate.ts",") вместо ",[185,468,469],{},"eval()"," или ",[185,472,473],{},"new Function()",[173,475,476,478,481,482,485,486,489,490,489,493,489,496,498],{},[192,477,288],{},[185,479,480],{},"new Function(expression)"," - вектор инъекции кода: вредоносная строка может выполнить произвольный JavaScript. Также требует ",[185,483,484],{},"unsafe-eval"," в Content-Security-Policy. Кастомный парсер поддерживает только ",[185,487,488],{},"+",", ",[185,491,492],{},"-",[185,494,495],{},"*",[185,497,242],{}," над числовыми литералами, отклоняя всё остальное.",[177,500,502],{"id":501},"каскадное-удаление-в-сторах","Каскадное удаление в сторах",[173,504,505,506,242,509,512],{},"Postgres не имеет FK-ограничений (см. выше). При вызове ",[185,507,508],{},"deleteWallet",[185,510,511],{},"deleteCategory"," стор также удаляет транзакции сущности из локальной SQLite (иначе подписка watch снова добавила бы их на следующем такте). При откате восстанавливаются и сущность, и её транзакции.",[173,514,515,518],{},[192,516,517],{},"Почему не полагаться на серверный каскад:","\nСерверного каскада нет (нет FK-ограничений). Клиент должен очищать локально, чтобы локальное SQLite-представление оставалось консистентным с предполагаемым состоянием.",[177,520,522,523],{"id":521},"вычисляемые-дочерние-категории-вместо-денормализованных-childids","Вычисляемые дочерние категории вместо денормализованных ",[185,524,525],{},"childIds",[173,527,528,529,532,533,188],{},"Категории хранят только ",[185,530,531],{},"parentId",". Дочерние ID вычисляются динамически фильтрацией категорий по ",[185,534,535],{},"parentId === id",[173,537,538,540,541,543,544,546],{},[192,539,288],{},"\nРанее родительские категории хранили массив ",[185,542,525],{},". Это требовало синхронизации обеих сторон при каждом перемещении, создании или удалении - источник багов. Вычисление детей из ",[185,545,531],{}," - единый источник правды без проблем синхронизации. Количество категорий достаточно мало (десятки-сотни), чтобы стоимость поиска была ничтожной.",[177,548,550],{"id":549},"ограничение-вложенности-категорий-до-2-уровней","Ограничение вложенности категорий до 2 уровней",[173,552,553],{},"Категории поддерживают только родитель - дочерний, без более глубокой вложенности.",[173,555,556,558],{},[192,557,288],{},"\nБолее глубокая вложенность усложняет UI (разбивка статистики, выбор категорий, хлебные крошки) без практической пользы для учёта личных финансов. Два уровня покрывают 99% случаев использования (например, Еда > Продукты).",[177,560,562],{"id":561},"приоритет-инъекции-темы","Приоритет инъекции темы",[173,564,565,566,569,570,573,574,188],{},"Стили темы используют ",[185,567,568],{},"tagPriority: -2"," в ",[185,571,572],{},"useHead()",", а скрипты плагинов используют ",[185,575,576],{},"tagPriority: -1",[173,578,579,581,582,585,586,589,590,593,594,596,597,335,600,603],{},[192,580,288],{},"\nЭто гарантирует применение CSS-переменных темы до первой отрисовки. Цепочка приоритетов: ",[185,583,584],{},"useHead"," стили (-2) - скрипты плагинов (-1) - ",[185,587,588],{},"@layer theme"," (всегда проигрывает non-layered). Non-layered CSS в ",[185,591,592],{},"theme.css"," переопределил бы ",[185,595,572],{}," порядком в документе, поэтому ",[185,598,599],{},"--ui-radius",[185,601,602],{},"--ui-primary"," не должны устанавливаться там.",[177,605,607],{"id":606},"следующие-шаги","Следующие шаги",[609,610,611,618,624],"ul",{},[612,613,614,617],"li",{},[615,616,97],"a",{"href":98}," - типобезопасная работа с периодами, интервалы, форматирование",[612,619,620,623],{},[615,621,622],{"href":133},"Стратегия валидации"," - клиентская валидация с Zod",[612,625,626,628],{},[615,627,108],{"href":109}," - как решения влияют на структуру проекта",{"title":630,"searchDepth":631,"depth":631,"links":632},"",2,[633,634,635,637,638,639,641,642,643,644,645,647,648,649],{"id":179,"depth":631,"text":180},{"id":230,"depth":631,"text":231},{"id":268,"depth":631,"text":636},"shallowRef для элементов сторов",{"id":297,"depth":631,"text":298},{"id":327,"depth":631,"text":328},{"id":365,"depth":631,"text":640},"Троттлинг reconcileTrns и подавление изменений",{"id":414,"depth":631,"text":415},{"id":438,"depth":631,"text":439},{"id":458,"depth":631,"text":459},{"id":501,"depth":631,"text":502},{"id":521,"depth":631,"text":646},"Вычисляемые дочерние категории вместо денормализованных childIds",{"id":549,"depth":631,"text":550},{"id":561,"depth":631,"text":562},{"id":606,"depth":631,"text":607},"Обоснование ключевых решений.","md",null,{},{"icon":130},{"title":656,"description":657},"Технические решения","Осознанные технические решения в Финапке и их обоснование - PowerSync offline-first, оптимистичный UI, клиентские UUID, жёсткая навигация при выходе, арифметический парсер и каскадное удаление.","qX4ZcconoqQX4uFCmiaNmvfX4yzbFKRoTei-tsIzBrM",[660,662],{"title":122,"path":123,"stem":124,"description":661,"icon":125,"children":-1},"Как PowerSync обеспечивает офлайн-записи, очередь загрузки и вывод ошибок.",{"title":132,"path":133,"stem":134,"description":663,"icon":135,"children":-1},"Клиентская валидация и компромиссы.",1782114341042]