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:
@@ -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)
|
- `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)
|
- `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)
|
## 8. Source Layout (Quick Reference)
|
||||||
|
|||||||
@@ -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.
|
- 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
|
### 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.
|
**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:
|
**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**.
|
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:**
|
**Example of the Bug:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -326,7 +367,7 @@ The `createLiveQueryStore` function creates a readable store that automatically
|
|||||||
|
|
||||||
## SvelteKit Layout Hierarchy: Security and Execution Order
|
## 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
|
### 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`
|
### 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
|
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:
|
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
|
## 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
|
### 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**.
|
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):**
|
**❌ 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
|
```svelte
|
||||||
<!-- Error: Can only bind to an Identifier or MemberExpression -->
|
<!-- Error: Can only bind to an Identifier or MemberExpression -->
|
||||||
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />
|
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id || null} />
|
||||||
|
|||||||
Reference in New Issue
Block a user