Files
OSIT-AE-App-Svelte/documentation/GUIDE__SvelteKit2_Svelte5_DexieJS.md
Scott Idem 65e48c764e docs: document build_tmp_sort pattern and liveQuery filter dependency capture
Added two new sections to GUIDE__SvelteKit2_Svelte5_DexieJS.md:
- IDB Sort: build_tmp_sort Pattern — sort chain, priority inversion
  encoding, anti-.reverse() warning, modules using it
- $derived.by Dependency Capture — SCENARIO 2 filter pattern and
  API snapshot consistency fix

Updated TODO__Agents.md:
- Added anti-.reverse() warning and guide pointer to build_tmp_sort entry
- Added Sessions hide/show toggle section with both fixes marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:24:10 -04:00

29 KiB

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).
// 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.

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:

// 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:

// 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:
<Comp_event_presentation_obj_li
    lq__event_presentation_obj_li={$lq__event_presentation_obj_li ?? data.initial_session_obj?.event_presentation_li ?? []}
    {log_lvl}
/>
  • 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:
$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.tsbuild_tmp_sort()

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 never use .reverse() on a list sorted by tmp_sort_*.reverse() would flip priority-true to sort last.

Modules using build_tmp_sort:

  • ae_events__event_presentation.tstmp_sort_1/2: group → priority → sort → start_datetime → code → name
  • ae_journals__journal.tstmp_sort_1/2/3: group → priority → sort → name → updated_on
  • ae_journals__journal_entry.ts — same chain as journal

Remaining modules (sessions, presenters, locations, posts, core) scheduled for rollout; 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.

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:

// 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):

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 awaits 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.

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 <slot> 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.

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.

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:

import { liveQuery } from 'dexie';
import { readable } from 'svelte/store';
import { db } from './db'; // Your Dexie database instance

export function createLiveQueryStore<T>(query: () => T | Promise<T>) {
    return readable<T | undefined>(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:

<script>
    import { createLiveQueryStore } from './stores';
    import { db } from './db';

    const friends = createLiveQueryStore(() => db.friends.toArray());
</script>

<ul>
    {#if $friends} {#each $friends as friend}
    <li>{friend.name}</li>
    {/each} {/if}
</ul>

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

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.

<!-- (idaa)/+layout.svelte -->
{:else if $ae_loc.trusted_access || $idaa_loc.novi_verified}
    {@render children?.()}   ← children only mount if this branch runs
{:else}
    <p>Access Denied</p>     ← 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:

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:

// 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.

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

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

<script>
    let { data } = $props();
    let lq__obj = $derived(liveQuery(async () => db.table.get(id)));
</script>

<!-- Use fallback to handle the gap before liveQuery emits -->
{#if $lq__obj || data.initial_obj}
    <View object={$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():

// 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():

// 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.

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

Correct Pattern: Ensure the source value is already normalized before binding, or use a reactive effect to handle the fallback.

// Normalize in an effect or derivation
$effect(() => {
    if ($events_slct.event_session_id === undefined) {
        $events_slct.event_session_id = null;
    }
});
<!-- Bind directly to the normalized property -->
<Launcher_menu bind:slct__event_session_id={$events_slct.event_session_id} />

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

// 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):

// 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):

// .reverse() is ignored by .sortBy()
let results = await db.table.where('id').equals(id).reverse().sortBy('sort_key');

Correct (Descending Sort Result):

// Sort ascending first, then reverse the array
let results = await db.table.where('id').equals(id).sortBy('sort_key');
return results.reverse();

References