docs: document bootstrap account_id race condition and liveQuery stale-record pattern

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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-02 13:39:11 -04:00
parent 5fce149808
commit a5243fa820
2 changed files with 95 additions and 5 deletions

View File

@@ -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)

View File

@@ -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
<!-- Error: Can only bind to an Identifier or MemberExpression -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />