# Stability Patterns for liveQuery + Svelte 5 Dexie's `liveQuery` works well with Svelte 5 runes, but the combination requires a few stable patterns so queries don't get recreated unintentionally and components render correctly on a "cold start" (empty IndexedDB). - Keep the observable instance stable: wrap `liveQuery` in a stable `$derived` so the observable isn't recreated on every render. Recreate the `liveQuery` only when explicit dependencies change (IDs, filters, or search keys). ```typescript // stable derived wrapper — only recreated when `id` changes let lq__obj = $derived( (() => { // capture the dependency(s) in a single stable closure const id = url_id; return liveQuery(async () => { if (!id) return null; console.log('[LQ] running for id=', id); return await db.table.get(id); }); })() ); ``` - Use `$derived.by(() => ...)` where available in your runes shim to build queries from a computed set of inputs (IDs list, search params). This preserves a stable observable instance while still reacting to explicit dependency changes. - Avoid capturing mutable objects or inline expressions in the `liveQuery` closure. If the closure captures a changing reference, the query may be recreated unexpectedly or miss the first write. ## Common Gotchas and Fixes (Why things sometimes need multiple refreshes) - Cold start (IDB empty) + non-blocking API writes: If you mount a component before data is written to IDB, `liveQuery` may run against an empty DB. The API write will populate IDB later, but sometimes a chain of dependent queries (e.g., presentations -> presenters) won't all rerun in the order you expect. The symptoms you described — session shows after one refresh, presenters only after a second — are consistent with either (a) queries recreated in the wrong order or (b) dependent store values being set only after some subscriptions are already created. ### Bootstrap Race: Account-scoped Loads Before `account_id` Is Set (2026-06) Account-scoped `liveQuery` triggers can fire before `+layout.svelte`'s bootstrap Sync Effect has propagated the real `account_id`. Two failure modes: 1. **IDB empty:** fetch runs with `account_id = null`. The `localStorage` scavenge in `api_get_object.ts` reads the stale value from a previous session — possibly a different account — and caches that wrong record into IDB. 2. **IDB has a stale record:** `liveQuery` returns a cached record from a different account as a valid hit, so the trigger condition (`!entry`) is never true and the correct record is never fetched. **Rule:** Gate any trigger `$effect` that loads account-scoped data on `$slct.account_id`, not `$ae_loc.account_id`. `$slct` is a plain writable store (not persisted), initialized to `null` and set _only_ by the bootstrap Sync Effect. `$ae_loc` is a persisted store that hydrates from `localStorage` before effects run and may carry a stale `account_id`. Also treat a non-null, non-matching `account_id` in an IDB record as a cache miss: ```typescript $effect(() => { const account_id = $slct.account_id; // null until bootstrap Sync Effect runs const api_ready = !!$ae_api?.base_url; const entry = $lq__obj as SomeType | null | undefined; if (!browser || !account_id || !api_ready) return; // null account_id on a record = global/shared fallback — still a valid hit. const entry_is_stale_account = entry !== undefined && entry !== null && entry.account_id !== null && entry.account_id !== account_id; if (!entry || entry_is_stale_account) { trigger = 'load...'; } }); ``` See `BOOTSTRAP__AI_Agent_Quickstart.md` → Section 7, entry 14 for the full incident writeup. ### Critical Discovery (2026-02-26): The "try_cache: false" Bug **Symptom:** Nested data (e.g., Session → Presentations → Presenters) requires multiple manual refreshes to display on cold-start, even when using blocking loads. **Root Cause:** Two interconnected issues in nested data loaders: 1. **Disabled caching in nested loads**: Parent loads were passing `try_cache: false` to child loads, meaning presentations and presenters were fetched from API but **never written to IndexedDB**. 2. **Missing microtask yields**: Even when caching was enabled, components would mount and subscribe to liveQuery _before_ IndexedDB writes completed, causing race conditions. **Example of the Bug:** ```typescript // Session loader (BROKEN) await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] }); // Loads presentations but disables caching ❌ return await load_presentations({ ..., try_cache: false }); // Presentations fetch from API ✅ // Presentations SKIP IndexedDB write ❌ // Presenters SKIP IndexedDB write ❌ // Component mounts, liveQuery finds only session ❌ ``` **The Fix:** ```typescript // Session loader (FIXED) await db_save_ae_obj_li__ae_obj({ table: 'session', obj_li: [session] }); await Promise.resolve(); // Yield to observers // Preserve parent's try_cache value ✅ return await load_presentations({ ..., try_cache }); // Presentations fetch AND write to IDB ✅ await Promise.resolve(); // Yield to observers // Presenters fetch AND write to IDB ✅ await Promise.resolve(); // Yield to observers // Component mounts, liveQuery finds all data ✅ ``` **Key Lessons:** 1. **Always preserve `try_cache` through nested loads** unless you have a specific reason to disable caching for that operation 2. **Add `await Promise.resolve()` after IndexedDB writes** to ensure Dexie's liveQuery observers fire before the function returns 3. **Block on nested loads with `await Promise.all()`** instead of fire-and-forget `forEach()` when the page needs complete data for first render Fixes: - Prefer the "Blocking Loader" when you can: `await` the API call in `+page.ts` so IDB is populated before Svelte mounts. - If you cannot block, return an `initial_*` object from `+page.ts` and use it as an immediate fallback in your component so the UI renders from that payload while `liveQuery` takes over for subsequent updates. Example from Aether: ```svelte ``` - Ensure store IDs are set before subscribers that depend on them are created. Use `untrack()` (or an equivalent non-reactive assignment) to set IDs in stores during initialization so components subscribe to the correct IDs immediately: ```typescript $effect(() => { if (!ae_acct) return; untrack(() => { $events_slct.event_id = url_event_id; $events_slct.event_session_id = url_session_id; }); }); ``` - When you have chains (presentations depend on session; presenters depend on presentation.person_id), make the dependent liveQuery explicitly wait for the upstream ID and log inside each query to verify the order — adding a small `await Promise.resolve()` or `await 0` inside the `liveQuery` is sometimes useful during debugging to ensure the JS microtask queue has a chance to settle after DB writes. ## IDB Sort: `build_tmp_sort` Pattern (2026-05) All Aether objects support `priority`, `sort`, `group`, and `name` fields. Rather than sorting in JS after a Dexie query (which requires `.reverse()` hacks and duplicated logic), pre-compute up to three `tmp_sort_*` string fields during the processing pipeline and store them in Dexie. Then `.sortBy('tmp_sort_2')` does the right thing in one call, with no `.reverse()`. **Utility:** `src/lib/ae_core/core__idb_sort.ts` — `build_tmp_sort()` ```typescript import { build_tmp_sort } from '$lib/ae_core/core__idb_sort'; // Inside specific_processor callback: const { tmp_sort_1, tmp_sort_2, tmp_sort_3 } = build_tmp_sort({ prefix: [obj.group ?? '0'], // always first priority: obj.priority, // boolean; true→'0' so ASC sorts it first sort: obj.sort, // zero-padded to 8 chars fields_1: [...], // module-specific tier-1 fields fields_2: [...], // tier-2 fields (tmp_sort_2 = base + tier-1 + tier-2) fields_3: [...] // tier-3 fields }); obj.tmp_sort_1 = tmp_sort_1; obj.tmp_sort_2 = tmp_sort_2; obj.tmp_sort_3 = tmp_sort_3; ``` **Sort chain convention:** `group → priority DESC → sort ASC → [module-specific] → name` **Priority encoding:** `priority ? '0' : '1'` — inverted so that `priority=true` sorts first in ascending order. This means: - **Dexie `.sortBy('tmp_sort_*')`** — always call without `.reverse()` before it (Dexie ignores collection-level `.reverse()` when using `.sortBy()`). If descending is needed for non-tmp_sort fields, call `.reverse()` on the resulting array after `await`. - **JS `.sort()` comparators** — use **ascending** `a.localeCompare(b)`, NOT `b.localeCompare(a)`. Using descending flips the priority encoding and puts `priority=false` items first. ```ts // ✅ Correct — ascending; priority=true ('0') sorts before priority=false ('1') list.sort((a, b) => (a.tmp_sort_1 ?? '').localeCompare(b.tmp_sort_1 ?? '')); // ❌ Wrong — descending inverts the encoding; priority=false ('1') sorts first list.sort((a, b) => (b.tmp_sort_1 ?? '').localeCompare(a.tmp_sort_1 ?? '')); ``` **Modules using `build_tmp_sort`:** - `ae_events__event_presentation.ts` — `tmp_sort_1/2`: group → priority → sort → start_datetime → code → name - `ae_events__event.ts` — `tmp_sort_1/2/3`: group → priority → sort → name → updated_on (used by IDAA recovery meetings) - `ae_journals__journal.ts` — `tmp_sort_1/2/3`: group → priority → sort → name → updated_on - `ae_journals__journal_entry.ts` — same chain as journal **Legacy encoding (not yet migrated to `build_tmp_sort`):** `ae_posts__post.ts`, `ae_posts__post_comment.ts`, `ae_archives__archive.ts`, `ae_archives__archive_content.ts`, `ae_sponsorships_functions.ts` use the opposite encoding (`priority ? '1' : '0'`, designed for descending sort). Their current route consumers sort by date/name so there is no visible priority bug today, but they must be migrated before any route starts sorting by `tmp_sort_*`. See `TODO__Agents.md`. --- ## `$derived.by` Dependency Capture for Extra Filter State When a `liveQuery` has a SCENARIO 2 fallback (broad search with no IDs), it may run before the debounced search fast path populates `event_session_id_li`. If that fallback doesn't apply the same visibility filter as the fast path, hidden items will briefly appear then disappear ("blink"). **Fix:** capture the filter flag as a `$derived.by` dependency in the outer closure so Svelte recreates the liveQuery instance whenever it changes — SCENARIO 2 then uses the correct filter from first render. ```typescript let lq__event_session_obj_li = $derived.by(() => { const ids = event_session_id_li; // drives SCENARIO 1 vs 2 const event_id = $events_slct?.event_id; const qry_hidden = pres_mgmt_loc.current.qry_hidden; // extra dependency return liveQuery(async () => { // SCENARIO 1 — specific IDs (fast path or API result) if (Array.isArray(ids) && ids.length > 0) { const results = await db.session.bulkGet(ids); return results.filter(Boolean); } // SCENARIO 2 — broad fallback, uses captured qry_hidden if (event_id && !someFilter) { const all = await db.session.where('event_id').equals(event_id).sortBy('name'); return all.filter((s: any) => { if (qry_hidden === 'not_hidden') return !s.hide; if (qry_hidden === 'hidden') return !!s.hide; return true; // 'all' }); } return []; }); }); ``` **Key rule:** anything read inside `$derived.by()`'s outer closure (but outside the `liveQuery` callback) becomes a Svelte reactive dependency. Changes to it recreate the liveQuery. Use this to synchronize filter flags that Dexie doesn't track. **Also fix the API call:** use the snapshot value from `params` (captured at debounce time) rather than the live store, so rapid toggling doesn't create a mismatch between fast path and API results: ```typescript // Bad — uses live store value, can race if user toggles during pending call: hidden: pres_mgmt_loc.current.qry_hidden ?? 'not_hidden' // Good — uses snapshot captured when handle_search_refresh was called: hidden: params.qry_hidden ?? 'not_hidden' ``` --- ## Practical Patterns from Aether (Journals & Events & IDAA Recovery Meetings) - Journals: The journaling pages use SWR-style background refreshes but reliably render because either (a) the page `+page.ts` blocks to populate DB for critical views, or (b) components accept `data.initial_*` fallback values until `liveQuery` emits. This hybrid approach avoids the "refresh twice" problem while keeping navigation snappy. - Journals broad views: if text search is empty, let the local IDB result set drive the visible list. The API can revalidate the cache in the background, but it should not replace a broad "All" view with a limited slice that hides valid rows. - Sessions / Presentations: The session page demonstrates several best practices: - Use `url_*` constants (derived from `data.params`) so the `liveQuery` closure captures a stable value instead of the reactive store directly. - Provide `initial_session_obj` from `+page.ts` as a first-draw fallback to child components. - Use `$derived.by(() => liveQuery(...))` for presentation lists so the observable instance is stable across renders and recreated only when `event_session_id` or `search` changes. - Search pages with persisted filters or saved query text should keep the auto-search trigger in a page-level `$effect`, but the duplicate guard should live inside the actual search executor. That preserves the first page-load search while blocking repeated identical reruns from localStorage-backed rerenders. In practice: - derive a single `qry_key` from the search inputs - debounce in the `$effect` - compare `qry_key` against a `last_executed_key` inside `handle_search_refresh()` - keep transient loading flags and trigger counters in session state when the value is only used to force a refresh, not as a persisted preference Example (presentation list pattern): ```typescript let lq__event_presentation_obj_li = $derived( liveQuery(async () => { if (!url_session_id) return []; console.log('[LQ] Querying Presentations for Session:', url_session_id); return await db_events.presentation.where('event_session_id').equals(url_session_id).sortBy('name'); }) ); ``` ## Debugging Checklist - Add a small `console.log` inside each `liveQuery` closure to confirm when it runs and what `id` it sees. - Verify that `+page.ts` either `await`s critical loads or returns `initial_*` payloads for first-render hydration. - Confirm that dependent store values (selected IDs) are assigned before components subscribe — use `untrack` to prevent extra reactive cycles. - If a search page stops auto-loading after a localStorage change, check whether the duplicate guard was placed in the `$effect` instead of the executor. Guarding too early can suppress the initial search; guard at execution time instead. - If a broad Dexie-backed list shows fewer rows than a narrower filter, look for a limit or revalidation step overwriting the local IDB result set. Broad views should stay unbounded unless the user is actually narrowing by text. - Ensure your `liveQuery` closures return quickly and do not throw; any exception inside the query can stop updates. - If a dependent query appears stale, temporarily add `await 0` in the upstream query or an explicit `Promise.resolve()` after the IDB write to force the microtask queue to flush during debugging. ## Summary Recommendations - Prefer blocking loads for primary views when first-render correctness matters. - Use `initial_*` fallback data when non-blocking loads are required. - Wrap `liveQuery` in stable `$derived` instances and only recreate when explicit inputs change. - Use `untrack` to set selection IDs during initialization to avoid subscribe-order bugs. - Add targeted logs inside `liveQuery` closures to diagnose ordering and subscription behavior. These patterns are deliberately conservative — they trade minimal blocking or small explicit fallbacks for predictable first-render behaviour. The Aether app's Journals and Event session pages are working examples of these techniques in practice. ## Examples in this repository The following files demonstrate stable `liveQuery` usage, `initial_*` fallbacks, and stable `$derived` wrappers used across the Aether app. Inspect these for copy/paste patterns and concrete implementations. - Journals page (stable LQ + search patterns): [src/routes/journals/[journal_id]/+page.svelte](src/routes/journals/[journal_id]/+page.svelte#L51) - Journals layout (blocking background loader): [src/routes/journals/[journal_id]/+layout.ts](src/routes/journals/[journal_id]/+layout.ts#L1) - Session page with URL capture + initial fallback: [src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/+page.svelte](src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/+page.svelte#L41) - Presentation management overview (stable derived + search): [src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/+page.svelte](src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/+page.svelte#L70) - Event settings example (simple observable): [src/routes/events/[event_id]/settings/+page.svelte](src/routes/events/[event_id]/settings/+page.svelte#L51) - Badge/detail pages (examples of nested LQ): [src/routes/events/[event_id]/(badges)/badges/+page.svelte](src/routes/events/[event_id]/(badges)/badges/+page.svelte#L66) Refer to these files when you need concrete code examples to adopt the patterns described above. ## References This document provides a guide to integrating Svelte (with a focus on Runes) and Dexie.js for building reactive web applications. It covers key concepts and best practices for managing reactivity between Svelte components and the Dexie.js database. ## Svelte 5 Migration Guide Svelte 5 introduces "runes" as a new way to manage reactivity. This is a major change from previous versions of Svelte, and it's important to understand the breaking changes before migrating. ### Key Breaking Changes - **`let` is no longer reactive:** In Svelte 4, any `let` variable declared in the top-level scope of a component was automatically reactive. In Svelte 5, you must explicitly declare reactive state using the `$state` rune. - **`$:` is replaced by `$derived` and `$effect`:** The `$` label is no longer used for reactive statements. Instead, you should use the `$derived` rune for computed values and the `$effect` rune for side effects. - **`export let` is replaced by `$props`:** Component props are now declared using the `$props` rune, which provides a more flexible and explicit way to define component APIs. - **Event handling:** The `on:` directive is replaced by event attributes (e.g., `onclick`). Component events are now handled using callback props instead of `createEventDispatcher`. - **Slots are replaced by snippets:** The `` element is replaced by the `{#snippet ...}` block, which provides a more powerful and flexible way to pass content to components. For a complete list of breaking changes, refer to the [Svelte 5 migration guide](https://svelte.dev/docs/svelte/v5-migration-guide). ## Dexie.js Quick Reference Dexie.js is a lightweight, minimalistic wrapper for IndexedDB that makes it easier to work with client-side databases. ### Key Classes and Methods - **`Dexie`:** The main class for creating and managing IndexedDB databases. - `new Dexie(databaseName)`: Creates a new database instance. - `version(versionNumber).stores({ ... })`: Defines the database schema. - **`Table`:** Represents an object store (table) in the database. - `add(item)`: Adds a new item to the table. - `put(item)`: Adds or updates an item in the table. - `update(key, changes)`: Updates an existing item. - `delete(key)`: Deletes an item by its primary key. - `get(key)`: Retrieves an item by its primary key. - `where(index)`: Starts a query using an index. - `toArray()`: Retrieves all items from the table as an array. - **`Collection`:** Represents a collection of items resulting from a query. - `toArray()`: Retrieves all items in the collection as an array. - `first()`: Retrieves the first item in the collection. - `last()`: Retrieves the last item in the collection. - `each(callback)`: Iterates over each item in the collection. - `modify(changes)`: Updates all items in the collection. - `delete()`: Deletes all items in the collection. For a complete list of API methods, refer to the [Dexie.js API Reference](https://dexie.org/docs/API-Reference). ## Integrating Svelte Runes and Dexie.js The combination of Svelte Runes and Dexie.js allows for the creation of highly reactive and efficient web applications. ### The `liveQuery` Function Dexie.js provides a `liveQuery` function that returns an observable of the query result. This observable can be used to automatically update the UI whenever the data in the database changes. ### Using `liveQuery` with Svelte Runes To use `liveQuery` with Svelte Runes, you can create a custom readable store that wraps the `liveQuery` observable. This store can then be used in your Svelte components to display and interact with the data. **1. Create a `liveQuery` store:** ```typescript import { liveQuery } from 'dexie'; import { readable } from 'svelte/store'; import { db } from './db'; // Your Dexie database instance export function createLiveQueryStore(query: () => T | Promise) { return readable(undefined, (set) => { const subscription = liveQuery(query).subscribe({ next: (result) => set(result), error: (error) => console.error(error) }); return () => subscription.unsubscribe(); }); } ``` **2. Use the `createLiveQueryStore` in your component:** ```html
    {#if $friends} {#each $friends as friend}
  • {friend.name}
  • {/each} {/if}
``` The `createLiveQueryStore` function creates a readable store that automatically updates whenever the data in the `friends` table changes. The `$friends` variable in the component will always contain the latest data from the database. ## SvelteKit Layout Hierarchy: Security and Execution Order Understanding _when_ SvelteKit code runs is critical for private-data modules like IDAA. ### Execution order on any navigation ```text 1. +layout.ts / +page.ts ← run FIRST — before any component mounts also fired by SvelteKit link prefetch (on hover) 2. Parent +layout.svelte mounts → its $effect blocks run 3. Child +layout.svelte mounts → only if parent called {#render children?.()} 4. +page.svelte mounts → only if every parent in the chain rendered children 5. $effect blocks in all of the above run after mount ``` ### The auth-gate consequence A `{:else if authenticated} {@render children?.()}` block in a `+layout.svelte` controls whether **everything below it** ever mounts. If the gate blocks rendering, no child layout or page component instantiates — their `$effect` blocks, event handlers, and liveQuery closures never run. ```svelte {:else if $ae_loc.trusted_access || $idaa_loc.novi_verified} {@render children?.()} ← children only mount if this branch runs {:else}

Access Denied

← children never mount; their $effects never run {/if} ``` **`$effect` blocks inside a child component cannot bypass a parent layout auth gate.** They are already inside the gate. Adding redundant auth guards to `$effect` blocks that only run after a parent has already verified access is unnecessary — and misleads future readers into thinking the parent gate alone is not sufficient. ### Where the actual pre-gate risk lives: `+page.ts` / `+layout.ts` Universal load functions run _before_ components mount and _before_ layout effects execute. They also fire during SvelteKit link prefetch — triggered by the user hovering a link, even if they never navigate. This makes them unsafe for private data: ```text User hovers an /idaa/ link → SvelteKit prefetch fires → +page.ts runs (no layout has mounted yet, no auth gate has run) → API call / IDB write happens for an unauthenticated user ``` **Rule for private modules (IDAA, Journals):** `+page.ts` and `+layout.ts` files must not call any data load functions that write to IDB. Move all data loading to `$effect` blocks in the corresponding `+page.svelte`, gated inside the auth-checked layout render. The comments in every `+page.ts` under `src/routes/idaa/(idaa)/` explain this pattern. ### The `$effect` auth guards in IDAA `+page.svelte` files These ARE still useful — but for a different reason than layout bypass: ```ts // In bb/+page.svelte $effect(() => { if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return; posts_func.load_ae_obj_li__post(...) }); ``` Because `$ae_loc` is a Svelte 4 coarse-grained store, any unrelated write to it (iframe height, SWR reload) re-triggers this `$effect`. The guard prevents a spurious API call if `$idaa_loc.novi_verified` has been cleared between re-runs (e.g. TTL expiry mid-session). It is a reactivity guard, not a layout-bypass guard. --- ## Page Load Strategies (Avoiding the "Waterfall") When loading data for a primary page view (e.g., viewing a specific Journal, Session, or Person), you must choose a synchronization strategy to ensure the UI renders correctly on the first load. ### ❌ The "Fire & Forget" Anti-Pattern (AVOID) Triggering a background load in `+page.ts` without `await` leads to race conditions. 1. `+page.svelte` mounts immediately. 2. `liveQuery` runs against an empty IndexedDB. 3. API data arrives later and writes to IndexedDB. 4. **Failure:** Svelte 5 + Dexie `liveQuery` may not automatically detect this first "cold start" update without a manual refresh. ### ✅ The "Blocking Loader" Pattern (RECOMMENDED) Ensure the data is in IndexedDB **before** the component mounts. 1. In `+page.ts`, `await` the API load function. 2. In `+page.svelte`, the `liveQuery` will see the data immediately upon mount. **Example (+page.ts):** ```typescript export async function load({ params }) { // Blocking await ensures IDB is populated await journals_func.load_ae_obj_id__journal({ journal_id: params.journal_id, try_cache: true }); return {}; } ``` ### ✅ The "Hydrate & Subscribe" Pattern (ADVANCED) If you must use non-blocking loads, you must pass the initial data to the component to "hydrate" the state before the subscription takes over. 1. In `+page.ts`, `await` the load and **return the object**. 2. In `+page.svelte`, use the returned object as a fallback or initial state. **Example (+page.svelte):** ```svelte {#if $lq__obj || data.initial_obj} {/if} ``` ## The `untrack()` Reactive-Tracking Trap `untrack()` is used inside `$effect` to read reactive values without registering them as tracked dependencies of that effect. This is correct for most "read-once" values (params, IDs) where you don't want the effect re-running on every change. But it has a silent failure mode: if a value you _need_ to re-read is consumed inside `untrack()`, the effect becomes a one-shot and never retries when that value changes. ### Symptom An effect runs once, reads a store value inside `untrack()`, takes an early-exit path (e.g. "no API key → skip"), and never retries — even after the store value is updated by a background process. ### Real Example (IDAA Novi Verification Bug — 2026-03-25) The IDAA layout verifies Novi UUIDs. `site_cfg_json` (which contains the Novi API key) was read **inside** `untrack()`: ```typescript // BUG: site_cfg_json read inside untrack → one-shot, never retries $effect(() => { if (!browser) return; const uuid = data.url.searchParams.get('uuid'); // tracked ✓ untrack(() => { const site_cfg_json = $ae_loc.site_cfg_json; // ← NOT tracked ✗ const api_key = site_cfg_json?.novi_idaa_api_key ?? null; if (!api_key) return; // exits silently on first load with stale cache verify_novi_uuid(uuid, api_key, ...); }); }); ``` On first load, the Dexie cache returned a stale `site_cfg_json` missing the API key. The effect exited early. The background refresh later updated `$ae_loc.site_cfg_json`, but because `site_cfg_json` was consumed inside `untrack()`, the effect never re-ran. **Fix:** Move the dependency read **outside** `untrack()`: ```typescript // FIX: site_cfg_json tracked outside untrack → effect re-runs when it changes $effect(() => { if (!browser) return; const uuid = data.url.searchParams.get('uuid'); // tracked ✓ const site_cfg_json = $ae_loc.site_cfg_json; // tracked ✓ — effect re-runs on change untrack(() => { // Guard: already verified for this UUID — don't repeat the round-trip if ($idaa_loc.novi_verified && $idaa_loc.novi_uuid === uuid) return; const api_key = site_cfg_json?.novi_idaa_api_key ?? null; if (!api_key) return; verify_novi_uuid(uuid, api_key, ...); }); }); ``` The guard inside `untrack()` is important: without it, every unrelated change to `$ae_loc` would re-trigger verification. ### Rule of Thumb Before wrapping a store read in `untrack()`, ask: **"Do I need this effect to re-run if this value changes?"** - If yes → read it **outside** `untrack()`, and add a guard inside to prevent redundant work. - If no → `untrack()` is correct. --- ## Svelte 5 Binding Pitfalls ### 1. `props_invalid_value` (The "Expression Binding" Error) Svelte 5's `bind:` directive is more restrictive than previous versions. You can only bind to a simple **Identifier** or **MemberExpression**. **❌ Invalid Pattern (Causes Compile Error):** Attempting to normalize a value _inside_ the binding will fail. ```svelte ``` **✅ Correct Pattern:** Ensure the source value is already normalized before binding, or use a reactive effect to handle the fallback. ```typescript // Normalize in an effect or derivation $effect(() => { if ($events_slct.event_session_id === undefined) { $events_slct.event_session_id = null; } }); ``` ```svelte ``` --- ## Safe Data Processing for IndexedDB Sorting When preparing data for IndexedDB, especially when creating composite sort keys, it is critical to handle `null` or `undefined` values safely to prevent runtime crashes that can interrupt the data synchronization process. ### 1. Safe String Padding Attempting to call `.toString()` or `.padStart()` on a `null` or `undefined` value will throw a `TypeError`. This is a common pitfall when processing optional fields like `sort` or `group`. **Bad Pattern (Crash Risk):** ```typescript // Crashes if obj.sort is null or undefined obj.tmp_sort_1 = `${obj.sort.toString().padStart(3, '0')}`; obj.tmp_sort_2 = `${obj.sort?.toString().padStart(3, '0') ?? ''}`; // Still risky if chaining is misunderstood ``` **Good Pattern (Safe):** ```typescript // Safely handle null/undefined by defaulting to 0 or an empty string BEFORE string manipulation const sort_val = (obj.sort ?? 0).toString().padStart(3, '0'); ``` ### 2. Correct Sorting with Dexie Dexie's `sortBy()` method returns a new array sorted by the specified key. It **ignores** previous `reverse()` calls on the collection. To achieve a descending sort, you must sort first and then reverse the resulting array. **Incorrect (Ascending Sort Result):** ```typescript // .reverse() is ignored by .sortBy() let results = await db.table.where('id').equals(id).reverse().sortBy('sort_key'); ``` **Correct (Descending Sort Result):** ```typescript // Sort ascending first, then reverse the array let results = await db.table.where('id').equals(id).sortBy('sort_key'); return results.reverse(); ``` ## References * https://dexie.org/llms.txt - Dexie.js and Dexie Cloud — LLM Guide and Documentation Summary