[{"data":1,"prerenderedAt":685},["ShallowReactive",2],{"navigation_docs_en":3,"-en-reference-tech-decisions":191,"-en-reference-tech-decisions-surround":680},[4,61,127,171],{"title":5,"icon":6,"path":7,"stem":8,"children":9,"page":60},"Guide","i-lucide-book-open","\u002Fen\u002Fguide","en\u002F1.guide",[10,15,20,25,30,35,40,45,50,55],{"title":11,"path":12,"stem":13,"icon":14},"Introduction","\u002Fen\u002Fguide\u002Fintroduction","en\u002F1.guide\u002F01.introduction","i-lucide-house",{"title":16,"path":17,"stem":18,"icon":19},"Install the App","\u002Fen\u002Fguide\u002Finstallation","en\u002F1.guide\u002F02.installation","i-lucide-smartphone",{"title":21,"path":22,"stem":23,"icon":24},"Authentication","\u002Fen\u002Fguide\u002Fauth","en\u002F1.guide\u002F03.auth","i-lucide-lock",{"title":26,"path":27,"stem":28,"icon":29},"Wallets","\u002Fen\u002Fguide\u002Fwallets","en\u002F1.guide\u002F04.wallets","i-lucide-wallet",{"title":31,"path":32,"stem":33,"icon":34},"Categories","\u002Fen\u002Fguide\u002Fcategories","en\u002F1.guide\u002F05.categories","i-lucide-tags",{"title":36,"path":37,"stem":38,"icon":39},"Transactions","\u002Fen\u002Fguide\u002Ftransactions","en\u002F1.guide\u002F06.transactions","i-lucide-receipt",{"title":41,"path":42,"stem":43,"icon":44},"Transfers","\u002Fen\u002Fguide\u002Ftransfers","en\u002F1.guide\u002F07.transfers","i-lucide-arrow-left-right",{"title":46,"path":47,"stem":48,"icon":49},"Statistics","\u002Fen\u002Fguide\u002Fstatistics","en\u002F1.guide\u002F08.statistics","i-lucide-bar-chart-3",{"title":51,"path":52,"stem":53,"icon":54},"Theme","\u002Fen\u002Fguide\u002Ftheme","en\u002F1.guide\u002F09.theme","i-lucide-palette",{"title":56,"path":57,"stem":58,"icon":59},"Settings","\u002Fen\u002Fguide\u002Fsettings","en\u002F1.guide\u002F10.settings","i-lucide-settings",false,{"title":62,"icon":63,"path":64,"stem":65,"children":66,"page":60},"Development","i-lucide-code","\u002Fen\u002Fdevelopment","en\u002F2.development",[67,72,77,82,87,92,97,102,122],{"title":68,"path":69,"stem":70,"icon":71},"Installation","\u002Fen\u002Fdevelopment\u002Finstallation","en\u002F2.development\u002F01.installation","i-lucide-download",{"title":73,"path":74,"stem":75,"icon":76},"Codebase Graph","\u002Fen\u002Fdevelopment\u002Funderstand-anything","en\u002F2.development\u002F02.understand-anything","i-lucide-network",{"title":78,"path":79,"stem":80,"icon":81},"Offline & PWA","\u002Fen\u002Fdevelopment\u002Foffline","en\u002F2.development\u002F03.offline","i-lucide-wifi-off",{"title":83,"path":84,"stem":85,"icon":86},"Data Migration History","\u002Fen\u002Fdevelopment\u002Fmigration","en\u002F2.development\u002F04.migration","i-lucide-database",{"title":88,"path":89,"stem":90,"icon":91},"Deployment","\u002Fen\u002Fdevelopment\u002Fdeployment","en\u002F2.development\u002F05.deployment","i-lucide-rocket",{"title":93,"path":94,"stem":95,"icon":96},"Testing","\u002Fen\u002Fdevelopment\u002Ftesting","en\u002F2.development\u002F06.testing","i-lucide-flask-conical",{"title":98,"path":99,"stem":100,"icon":101},"Date Utilities","\u002Fen\u002Fdevelopment\u002Fdate-utilities","en\u002F2.development\u002F07.date-utilities","i-lucide-calendar",{"title":103,"path":104,"stem":105,"children":106,"page":60},"Ai Workflow","\u002Fen\u002Fdevelopment\u002Fai-workflow","en\u002F2.development\u002F08.ai-workflow",[107,112,117],{"title":108,"path":109,"stem":110,"icon":111},"Overview","\u002Fen\u002Fdevelopment\u002Fai-workflow\u002Foverview","en\u002F2.development\u002F08.ai-workflow\u002F01.overview","i-lucide-bot",{"title":113,"path":114,"stem":115,"icon":116},"Agents","\u002Fen\u002Fdevelopment\u002Fai-workflow\u002Fagents","en\u002F2.development\u002F08.ai-workflow\u002F02.agents","i-lucide-users",{"title":118,"path":119,"stem":120,"icon":121},"Skills","\u002Fen\u002Fdevelopment\u002Fai-workflow\u002Fskills","en\u002F2.development\u002F08.ai-workflow\u002F03.skills","i-lucide-lightbulb",{"title":123,"path":124,"stem":125,"icon":126},"Troubleshooting","\u002Fen\u002Fdevelopment\u002Ftroubleshooting","en\u002F2.development\u002F09.troubleshooting","i-lucide-life-buoy",{"title":128,"icon":129,"path":130,"stem":131,"children":132,"page":60},"Reference","i-lucide-file-code","\u002Fen\u002Freference","en\u002F3.reference",[133,138,142,147,152,156,161,166],{"title":134,"path":135,"stem":136,"icon":137},"Architecture","\u002Fen\u002Freference\u002Farchitecture","en\u002F3.reference\u002F01.architecture","i-lucide-boxes",{"title":139,"path":140,"stem":141,"icon":44},"Transaction Types","\u002Fen\u002Freference\u002Ftransaction-types","en\u002F3.reference\u002F02.transaction-types",{"title":143,"path":144,"stem":145,"icon":146},"Sync","\u002Fen\u002Freference\u002Fsync","en\u002F3.reference\u002F03.sync","i-lucide-refresh-cw",{"title":148,"path":149,"stem":150,"icon":151},"Offline-first","\u002Fen\u002Freference\u002Foffline-first","en\u002F3.reference\u002F04.offline-first","i-lucide-list-ordered",{"title":153,"path":154,"stem":155,"icon":121},"Technical Decisions","\u002Fen\u002Freference\u002Ftech-decisions","en\u002F3.reference\u002F05.tech-decisions",{"title":157,"path":158,"stem":159,"icon":160},"Validation Strategy","\u002Fen\u002Freference\u002Fvalidation-strategy","en\u002F3.reference\u002F06.validation-strategy","i-lucide-shield-check",{"title":162,"path":163,"stem":164,"icon":165},"What Changed Since Firebase","\u002Fen\u002Freference\u002Ffirebase-migration","en\u002F3.reference\u002F07.firebase-migration","i-lucide-hamburger",{"title":167,"path":168,"stem":169,"icon":170},"Performance","\u002Fen\u002Freference\u002Fperformance","en\u002F3.reference\u002F08.performance","i-lucide-gauge",{"title":172,"icon":173,"path":174,"stem":175,"children":176,"page":60},"Premium","i-lucide-star","\u002Fen\u002Fpremium","en\u002F4.premium",[177,181,186],{"title":108,"path":178,"stem":179,"icon":180},"\u002Fen\u002Fpremium\u002Foverview","en\u002F4.premium\u002F01.overview","i-lucide-layers",{"title":182,"path":183,"stem":184,"icon":185},"Telegram Bot","\u002Fen\u002Fpremium\u002Ftelegram-bot","en\u002F4.premium\u002F02.telegram-bot","i-lucide-send",{"title":187,"path":188,"stem":189,"icon":190},"AI Chat","\u002Fen\u002Fpremium\u002Fai-chat","en\u002F4.premium\u002F03.ai-chat","i-lucide-sparkles",{"id":192,"title":153,"body":193,"description":672,"extension":673,"links":674,"meta":675,"navigation":676,"path":154,"seo":677,"stem":155,"__hash__":679},"docs_en\u002Fen\u002F3.reference\u002F05.tech-decisions.md",{"type":194,"value":195,"toc":651},"minimark",[196,200,205,213,224,238,252,256,275,281,290,297,308,319,323,330,343,349,353,368,387,394,406,412,435,439,453,459,463,470,479,483,497,522,526,536,542,549,559,570,574,577,582,586,600,627,631],[197,198,199],"p",{},"Conscious technical decisions and their rationale.",[201,202,204],"h2",{"id":203},"powersync-offline-first-local-sqlite-as-source-of-truth","PowerSync offline-first (local SQLite as source of truth)",[197,206,207,208,212],{},"The app uses PowerSync to replicate Supabase Postgres into a local WASM SQLite database on the client. Stores read exclusively from that local SQLite via ",[209,210,211],"code",{},"watchTable('SELECT * FROM ...', [], cb)",".",[197,214,215,219,220,223],{},[216,217,218],"strong",{},"Why local SQLite as the source of truth:","\nThis makes the app fully functional without network connectivity. Reads are instant (no round-trip). The single ",[209,221,222],{},"watchTable"," subscription fires immediately with current local rows AND again on every change - whether from a local write or an incoming server sync. This single mechanism replaces the old split between initial load and realtime subscription.",[197,225,226,229,230,233,234,237],{},[216,227,228],{},"Why PowerSync over a custom offline queue:","\nThe previous backend used a hand-written offline queue (",[209,231,232],{},"components\u002Foffline\u002Freplay*",") with XOR-hash deletion detection, local ID remapping, and paginated delta fetching. PowerSync handles all of that automatically: it maintains a local CRUD queue, drains it to Supabase via the connector's ",[209,235,236],{},"uploadData",", and reconciles server changes via logical replication. No custom queue code, no hash mismatch logic, no ID remapping.",[197,239,240,243,244,247,248,251],{},[216,241,242],{},"Why IDBBatchAtomicVFS (IndexedDB backend) instead of OPFS:","\nOPFS (Origin Private File System) is faster but requires ",[209,245,246],{},"SharedArrayBuffer",", which requires COOP\u002FCOEP cross-origin isolation headers. Those headers break Google Fonts, Iconify CDN, and other third-party resources. ",[209,249,250],{},"IDBBatchAtomicVFS"," (IndexedDB-backed) needs no cross-origin isolation and works in a standard deployment.",[201,253,255],{"id":254},"optimistic-writes-with-rollback","Optimistic writes with rollback",[197,257,258,259,262,263,266,267,270,271,274],{},"Store methods update ",[209,260,261],{},"items"," immediately (optimistic), then call ",[209,264,265],{},"upsertRow","\u002F",[209,268,269],{},"deleteRow"," to write to local SQLite. If the local write fails, the optimistic change is rolled back by restoring a ",[209,272,273],{},"prev"," snapshot, and an error toast is shown.",[197,276,277,280],{},[216,278,279],{},"Why optimistic instead of await:","\nAwaiting the SQLite write would block the UI, even though it is a local in-process operation. Optimistic updates give instant feedback. The rollback path handles the rare case where the local write itself fails (e.g., transaction conflict).",[197,282,283,286,287,289],{},[216,284,285],{},"Note on server rejection:"," server-side errors (Postgres RLS violations, data exceptions) are surfaced separately by the connector's ",[209,288,236],{}," upload-error handler, not the local write catch.",[201,291,293,296],{"id":292},"shallowref-for-store-items",[209,294,295],{},"shallowRef"," for store items",[197,298,299,300,303,304,307],{},"All Pinia stores use ",[209,301,302],{},"shallowRef\u003CMap | null>"," instead of ",[209,305,306],{},"ref"," for the items collection. Store methods create new object references on every mutation to trigger reactivity.",[197,309,310,313,315,316,318],{},[216,311,312],{},"Why:",[209,314,306],{}," would deep-track every nested property of potentially thousands of transaction\u002Fwallet\u002Fcategory objects. ",[209,317,295],{}," only tracks the top-level reference. The explicit \"create new object\" pattern on write is a deliberate trade-off: slightly more verbose stores, significantly lower reactivity overhead.",[201,320,322],{"id":321},"client-generated-uuids-no-remapping","Client-generated UUIDs (no remapping)",[197,324,325,326,329],{},"Every new entity gets a UUID generated on the client (",[209,327,328],{},"crypto.randomUUID()"," or equivalent) before the local write. That UUID is the permanent ID - the client owns it for the entire offline lifecycle and it is never remapped.",[197,331,332,334,335,338,339,342],{},[216,333,312],{},"\nPowerSync queues a write locally and uploads it later, so the ID must be stable before the server ever sees the row. Supabase schema uses ",[209,336,337],{},"text"," PKs with ",[209,340,341],{},"default gen_random_uuid()::text",", so whatever UUID the client generates is accepted as-is. No server round-trip to assign IDs, no remapping, no cascade updates to rows that reference a newly-created entity.",[197,344,345,348],{},[216,346,347],{},"Why no FK constraints in Postgres:","\nPowerSync upload order is not guaranteed. A transaction referencing a wallet may be uploaded before the wallet itself. FK constraints would reject valid data. Referential integrity is enforced by app logic instead.",[201,350,352],{"id":351},"sqlite-views-need-insertupdate-not-upsert","SQLite views need INSERT\u002FUPDATE, not upsert",[197,354,355,356,359,360,363,364,367],{},"PowerSync client tables are SQLite views backed by INSTEAD OF triggers. They accept plain ",[209,357,358],{},"INSERT"," and ",[209,361,362],{},"UPDATE"," but do NOT support ",[209,365,366],{},"INSERT ... ON CONFLICT"," (upsert syntax).",[197,369,370,376,377,379,380,382,383,386],{},[216,371,372,373,375],{},"How ",[209,374,265],{}," handles this:","\nIt performs an existence check, then either ",[209,378,358],{}," (new row) or ",[209,381,362],{}," (existing row), wrapped in a ",[209,384,385],{},"writeTransaction"," for atomicity. The caller always owns the ID, so INSERT\u002FUPDATE is unambiguous.",[201,388,390,393],{"id":389},"reconciletrns-throttle-and-change-suppression",[209,391,392],{},"reconcileTrns"," throttle and change suppression",[197,395,396,399,400,402,403,405],{},[209,397,398],{},"useTrnsStore"," uses a 120ms throttle window on its ",[209,401,222],{}," (vs. the default 30ms for other tables). It also uses ",[209,404,392],{}," to suppress the \"echo\" of its own optimistic write.",[197,407,408,411],{},[216,409,410],{},"Why the larger throttle:","\nTransactions are the largest table. During initial sync, PowerSync emits many rapid row-change events. The 120ms trailing coalesce folds the burst into a single re-query, reducing CPU.",[197,413,414,420,421,423,424,427,428,430,431,434],{},[216,415,416,417,419],{},"Why ",[209,418,392],{},":","\nAfter an optimistic write the watch fires again with the same data (the echo of the write). ",[209,422,392],{}," detects that nothing actually changed (comparing by ",[209,425,426],{},"updatedAt",") and returns the same ",[209,429,273],{}," ref unchanged, suppressing a redundant reactivity cycle. It also reuses unchanged row objects so ",[209,432,433],{},"rowToTrn"," only runs for the delta.",[201,436,438],{"id":437},"cross-user-wipe-before-connect-fail-closed","Cross-user wipe before connect (fail-closed)",[197,440,441,444,445,448,449,452],{},[209,442,443],{},"connectPowerSync"," checks a persisted ",[209,446,447],{},"finapp.psDbOwnerUid"," (localStorage). If local SQLite belongs to a different user, it calls ",[209,450,451],{},"disconnectAndClear()"," before connecting - wiping the local database first.",[197,454,455,458],{},[216,456,457],{},"Why fail-closed:","\nIf the wipe itself fails, the owner marker is kept. The next different-user session will still attempt a wipe before reading any data. This prevents user A's data from leaking to user B on a shared device. Accepting a failed wipe and proceeding would be a data privacy bug.",[201,460,462],{"id":461},"hard-navigation-on-sign-out","Hard navigation on sign-out",[197,464,465,466,469],{},"Sign-out uses ",[209,467,468],{},"window.location.href = '\u002Flogin'"," instead of Vue Router navigation.",[197,471,472,474,475,478],{},[216,473,312],{},"\nHard navigation destroys all JavaScript state - Pinia stores, WebSocket connections, reactive watchers, cached data. This prevents stale auth state from leaking into the next session. Vue Router ",[209,476,477],{},"navigateTo()"," would keep all in-memory state alive.",[201,480,482],{"id":481},"safe-arithmetic-parser-in-calculator","Safe arithmetic parser in calculator",[197,484,485,486,489,490,493,494,212],{},"The transaction form calculator uses a recursive descent parser (",[209,487,488],{},"app\u002Fcomponents\u002FtrnForm\u002Futils\u002Fcalculate.ts",") instead of ",[209,491,492],{},"eval()"," or ",[209,495,496],{},"new Function()",[197,498,499,501,504,505,508,509,512,513,512,516,512,519,521],{},[216,500,312],{},[209,502,503],{},"new Function(expression)"," is a code injection vector - a malicious string could execute arbitrary JavaScript. It also requires ",[209,506,507],{},"unsafe-eval"," in Content-Security-Policy. The custom parser supports only ",[209,510,511],{},"+",", ",[209,514,515],{},"-",[209,517,518],{},"*",[209,520,266],{}," on numeric literals, rejecting everything else.",[201,523,525],{"id":524},"cascade-deletes-in-stores","Cascade deletes in stores",[197,527,528,529,266,532,535],{},"Postgres has no FK constraints (see above). When ",[209,530,531],{},"deleteWallet",[209,533,534],{},"deleteCategory"," is called, the store also deletes the entity's transactions from local SQLite (otherwise the watch subscription would re-add them on the next tick). Rollback restores both the entity and its transactions.",[197,537,538,541],{},[216,539,540],{},"Why not rely on server cascade:","\nThere is no server-side cascade (no FK constraints). The client must clean up locally so the local SQLite view stays consistent with the intended state.",[201,543,545,546],{"id":544},"computed-children-instead-of-denormalized-childids","Computed children instead of denormalized ",[209,547,548],{},"childIds",[197,550,551,552,555,556,212],{},"Categories store only ",[209,553,554],{},"parentId",". Child IDs are computed dynamically by filtering categories where ",[209,557,558],{},"parentId === id",[197,560,561,563,564,566,567,569],{},[216,562,312],{},"\nPreviously, parent categories stored a ",[209,565,548],{}," array. This required keeping both sides in sync on every reparent, create, or delete - a source of bugs. Computing children from ",[209,568,554],{}," is a single source of truth with no sync issues. The category count is small enough (tens to hundreds) that the lookup cost is negligible.",[201,571,573],{"id":572},"category-nesting-limited-to-2-levels","Category nesting limited to 2 levels",[197,575,576],{},"Categories support only parent to child, not deeper nesting.",[197,578,579,581],{},[216,580,312],{},"\nDeeper nesting complicates the UI (statistics breakdown, category picker, breadcrumbs) without adding practical value for personal finance tracking. Two levels cover 99% of use cases (e.g., Food > Groceries).",[201,583,585],{"id":584},"theme-injection-priority","Theme injection priority",[197,587,588,589,592,593,596,597,212],{},"Theme styles use ",[209,590,591],{},"tagPriority: -2"," in ",[209,594,595],{},"useHead()",", while plugin scripts use ",[209,598,599],{},"tagPriority: -1",[197,601,602,604,605,608,609,612,613,616,617,619,620,359,623,626],{},[216,603,312],{},"\nThis ensures theme CSS variables are applied before the first paint. The priority chain: ",[209,606,607],{},"useHead"," styles (-2) - plugin scripts (-1) - ",[209,610,611],{},"@layer theme"," (always loses to non-layered). Non-layered CSS in ",[209,614,615],{},"theme.css"," would override ",[209,618,595],{}," by document order, which is why ",[209,621,622],{},"--ui-radius",[209,624,625],{},"--ui-primary"," must not be set there.",[201,628,630],{"id":629},"next-steps","Next Steps",[632,633,634,641,646],"ul",{},[635,636,637,640],"li",{},[638,639,98],"a",{"href":99}," - type-safe period handling, interval computation, formatting",[635,642,643,645],{},[638,644,157],{"href":158}," - client-only validation with Zod",[635,647,648,650],{},[638,649,134],{"href":135}," - how decisions shape the project structure",{"title":652,"searchDepth":653,"depth":653,"links":654},"",2,[655,656,657,659,660,661,663,664,665,666,667,669,670,671],{"id":203,"depth":653,"text":204},{"id":254,"depth":653,"text":255},{"id":292,"depth":653,"text":658},"shallowRef for store items",{"id":321,"depth":653,"text":322},{"id":351,"depth":653,"text":352},{"id":389,"depth":653,"text":662},"reconcileTrns throttle and change suppression",{"id":437,"depth":653,"text":438},{"id":461,"depth":653,"text":462},{"id":481,"depth":653,"text":482},{"id":524,"depth":653,"text":525},{"id":544,"depth":653,"text":668},"Computed children instead of denormalized childIds",{"id":572,"depth":653,"text":573},{"id":584,"depth":653,"text":585},{"id":629,"depth":653,"text":630},"Rationale behind key design choices.","md",null,{},{"icon":121},{"title":153,"description":678},"Technical decisions in Finapp and their rationale - PowerSync offline-first, optimistic UI, client-generated UUIDs, hard sign-out navigation, arithmetic parser, and cascade deletes.","JlE9a6ykxCLv1Q3B0HGwqRcow28mD8Wt0Cx_QdlDSfQ",[681,683],{"title":148,"path":149,"stem":150,"description":682,"icon":151,"children":-1},"How PowerSync enables offline writes, upload queue, and error surfacing.",{"title":157,"path":158,"stem":159,"description":684,"icon":160,"children":-1},"Client-only validation approach and trade-offs.",1782114344746]