From a5243fa820a30e0a5f516a750e632ea3ac991519 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 2 Jun 2026 13:39:11 -0400 Subject: [PATCH] docs: document bootstrap account_id race condition and liveQuery stale-record pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds entry #14 to BOOTSTRAP__AI_Agent_Quickstart.md (section 7 "Mistakes Agents Have Made") and a new "Bootstrap Race" subsection to GUIDE__SvelteKit2_Svelte5_DexieJS.md ("Common Gotchas"), capturing the fix from 5fce14980: gate account-scoped liveQuery triggers on $slct.account_id (non-persisted), not $ae_loc.account_id (persisted, potentially stale), and treat IDB records from a different non-null account as a cache miss. Also fixes five pre-existing MD049 emphasis style warnings (asterisk → underscore) in the Dexie guide. Co-Authored-By: Claude Sonnet 4.6 --- .../BOOTSTRAP__AI_Agent_Quickstart.md | 49 ++++++++++++++++++ .../GUIDE__SvelteKit2_Svelte5_DexieJS.md | 51 +++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/documentation/BOOTSTRAP__AI_Agent_Quickstart.md b/documentation/BOOTSTRAP__AI_Agent_Quickstart.md index 680b1169..b3ea4bb4 100644 --- a/documentation/BOOTSTRAP__AI_Agent_Quickstart.md +++ b/documentation/BOOTSTRAP__AI_Agent_Quickstart.md @@ -397,6 +397,55 @@ These are real incidents — know them before you start. - `timeout = 20000` default (was 60s in PATCH/DELETE until 2026-05-21 — 5-min worst case) - `did_timeout_abort` flag per attempt (separates helper timeouts from caller aborts) +14. **Account-scoped `liveQuery` trigger firing before bootstrap completes** — components + that load account-specific data via `liveQuery` must not trigger the API fetch until the + bootstrap Sync Effect in `+layout.svelte` has set the real `account_id`. + + **What happened:** `element_data_store.svelte` triggered its load when `entry` was falsy. + On a fresh load with no IDB cache, `$ae_api.account_id` was still `null` (bootstrap hadn't + run yet). The `localStorage` scavenge in `api_get_object.ts` then read the stale + `account_id = 1` from a previous dev/demo session and made the API call with the wrong + account. The response was cached in IDB, and the next page load showed the wrong account's + record. + + A second failure mode: if IDB _did_ have a cached record from a previous session with a + different account, `liveQuery` returned it as a valid hit (`entry` truthy), so the trigger + never fired to fetch the correct record. + + **The fix pattern** for any trigger `$effect` that depends on bootstrapped account context: + ```typescript + $effect(() => { + // Use $slct.account_id (non-persisted), NOT $ae_loc.account_id (persisted, stale). + // $slct is initialized to null and set only by the bootstrap Sync Effect, so it + // reliably gates the fetch until bootstrap has completed. + const account_id = $slct.account_id; + const api_ready = !!$ae_api?.base_url; + const entry = $lq__ds_obj as SomeType | null | undefined; + + if (!browser || !account_id || !api_ready) return; + + // Also re-fetch when IDB holds a record from a different (non-null) account. + // null account_id = global/shared fallback — that is still a valid cache 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...'; + } + }); + ``` + + **Why `$slct` not `$ae_loc`:** + `$ae_loc` is a `svelte-persisted-store` — it hydrates from `localStorage` before any + effects run, so its `account_id` may be a stale value from a previous session. `$slct` + is a plain writable store initialized to `null`; the bootstrap Sync Effect is the only + thing that sets it. Until that runs, `$slct.account_id` is `null`, providing a reliable + gate. See `GUIDE__SvelteKit2_Svelte5_DexieJS.md` → "Bootstrap Race" for the Dexie-side + context. + --- ## 8. Source Layout (Quick Reference) diff --git a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md index 936a063d..d8c5da06 100644 --- a/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md +++ b/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md @@ -25,13 +25,54 @@ let lq__obj = $derived( - 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. +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 @@ -326,7 +367,7 @@ The `createLiveQueryStore` function creates a readable store that automatically ## SvelteKit Layout Hierarchy: Security and Execution Order -Understanding *when* SvelteKit code runs is critical for private-data modules like IDAA. +Understanding _when_ SvelteKit code runs is critical for private-data modules like IDAA. ### Execution order on any navigation @@ -362,7 +403,7 @@ 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 +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: @@ -446,7 +487,7 @@ If you must use non-blocking loads, you must pass the initial data to the compon ## 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. +`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 @@ -510,7 +551,7 @@ Before wrapping a store read in `untrack()`, ask: **"Do I need this effect to re 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. +Attempting to normalize a value _inside_ the binding will fail. ```svelte