fix(scope): bridge active-space / user changes to Dexie liveQuery

Modules mounted before the active-space bootstrap finished rendered
empty on first paint and stayed empty until some unrelated Dexie
write happened to wake the querier up. Reproducible in wardrobe:
open the module, the face-ref banner stays visible (even though
face-ref is set), the garment grid is empty; create a new garment
and suddenly the existing ones appear alongside it.

Root cause: Dexie's `liveQuery` only re-runs when a Dexie table it
read during evaluation is written to. `getInScopeSpaceIds()` reads
from plain Svelte `$state` (active-space.svelte.ts `active` +
current-user.ts `currentUserId`), which is invisible to Dexie's
change tracker. So:

1. User opens /wardrobe.
2. First querier runs. getActiveSpaceId() is still null (bootstrap
   hasn't resolved yet). getInScopeSpaceIds() returns
   [`_personal:guest`].
3. Existing rows are stamped `_personal:<userId>` or a real
   space-id — no match. Filter emits [].
4. Bootstrap resolves, setActiveSpace fires, `active = realSpace`.
   Svelte $state assignment: invisible to Dexie.
5. liveQuery never re-runs; user sees empty until a subsequent
   Dexie write forces a re-evaluation.

Fix: add `_scopeCursor`, a single-row infra table, as a Dexie
proxy for the scope-state signal.

- `data/scope/cursor.ts`: new module with two exports.
  `bumpScopeCursor()` writes `{id:'active', bumpedAt}` to the
  cursor. `touchScopeCursor()` reads from it, fire-and-forget —
  the Dexie read registers the liveQuery subscription during
  querier execution.
- Dexie v45 registers `_scopeCursor: 'id'`. NOT in SYNC_APP_MAP:
  it's a client-side liveness signal, not user data, so the
  creating-hook loop ignores it and no pending-change rows are
  generated.
- `scopedTable` / `scopedGet` / `scopedAnd` in `scoped-db.ts` call
  `touchScopeCursor()` on every invocation. Since those run inside
  each querier's body, liveQuery picks up the subscription for free.
- `setActiveSpace` (direct + both loadActiveSpace branches) calls
  `bumpScopeCursor()` after the `$state` update.
- `setCurrentUserId` in current-user.ts calls `bumpScopeCursor()`
  via dynamic import so the module stays leaf-level (no eager
  Dexie dep in test envs that mock fake-indexeddb differently).

Net effect: every scope change triggers one Dexie write to an
infra table, every liveQuery that went through `scopedForModule`
sees the write and re-evaluates with the fresh
`getInScopeSpaceIds()`. The first-mount race that left wardrobe
stuck on an empty list is gone.

Existing 14 scope regression tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 11:32:32 +02:00
parent 3f438cf882
commit d8feef1149
5 changed files with 115 additions and 4 deletions

View file

@ -17,9 +17,23 @@ export const GUEST_USER_ID = 'guest';
let currentUserId: string | null = null;
/** Updates the active user. Pass `null` for sign-out / guest. */
/**
* Updates the active user. Pass `null` for sign-out / guest.
*
* After updating the in-memory value, bumps the Dexie `_scopeCursor`
* (lazy import to keep this module leaf-level) so every liveQuery
* subscribed via `touchScopeCursor` re-evaluates with the new
* `getInScopeSpaceIds()`. Fire-and-forget a missing bump only
* delays re-evaluation until the next Dexie write elsewhere.
*/
export function setCurrentUserId(id: string | null): void {
const prev = currentUserId;
currentUserId = id;
if (id !== prev) {
void import('./scope/cursor').then(({ bumpScopeCursor }) => {
bumpScopeCursor();
});
}
}
/** Returns the active user id, or `null` if unauthenticated. */

View file

@ -1052,6 +1052,17 @@ db.version(44).stores({
comicStories: 'id, createdAt, style, isFavorite, isArchived',
});
// v45 — Infra table `_scopeCursor` (see data/scope/cursor.ts for the
// full rationale). Single-row beacon that every scoped query touches
// so Dexie's liveQuery subscribes to it; bumped on every
// setActiveSpace. Without this, scope changes were invisible to
// liveQueries and modules rendered empty on first mount until an
// unrelated write re-triggered the querier. NOT in SYNC_APP_MAP —
// it's a client-side liveness signal, not user data.
db.version(45).stores({
_scopeCursor: 'id',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -12,6 +12,7 @@
import type { SpaceType, SpaceTier } from '@mana/shared-types';
import { isSpaceType, isSpaceTier } from '@mana/shared-types';
import { authFetch } from './auth-fetch';
import { bumpScopeCursor } from './cursor';
export interface ActiveSpace {
id: string;
@ -91,7 +92,14 @@ export function setActiveSpace(space: ActiveSpace | null): void {
active = space;
status = space ? 'ready' : 'idle';
lastError = null;
if (space?.id !== prevId) notifyHandlers(space);
if (space?.id !== prevId) {
notifyHandlers(space);
// Dexie-bridge: bump the _scopeCursor so every liveQuery that
// touchScopeCursor'd re-runs with the new getInScopeSpaceIds().
// Without this, modules mounted before the bootstrap resolved
// the active space sit on an empty first result forever.
bumpScopeCursor();
}
}
/**
@ -164,7 +172,10 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
active = member;
status = 'ready';
writeActiveSpaceHint(member.id);
if (member.id !== prevId) notifyHandlers(member);
if (member.id !== prevId) {
notifyHandlers(member);
bumpScopeCursor();
}
return member;
}
@ -186,7 +197,10 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
active = { ...chosen, role: hinted ? hinted.role : 'owner' };
status = 'ready';
writeActiveSpaceHint(chosen.id);
if (active.id !== prevId) notifyHandlers(active);
if (active.id !== prevId) {
notifyHandlers(active);
bumpScopeCursor();
}
return active;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);

View file

@ -0,0 +1,61 @@
/**
* Dexie bridge for the scope-change signal.
*
* Problem this solves: `getInScopeSpaceIds()` reads from plain Svelte
* `$state` (`active-space.svelte.ts#active` + `current-user.ts#currentUserId`).
* Dexie's `liveQuery` only re-runs when a Dexie table it read changes;
* a `$state` assignment is invisible to it. The bootstrap sequence
* user opens `/wardrobe`, first querier runs before the active-space
* is resolved from Better Auth, returns empty, setActiveSpace fires,
* but the liveQuery never re-evaluates left modules stuck on an
* empty first result until an unrelated write woke them up.
*
* This module is the Dexie proxy. A single-row `_scopeCursor` table
* (schema: `_scopeCursor: 'id'`, registered in Dexie v45) acts as the
* change beacon. `bumpScopeCursor()` writes a new `{id:'active',
* bumpedAt}` row every time scope state changes; `touchScopeCursor()`
* reads the row on every scoped query so liveQuery subscribes to the
* table. Net effect: a scope change triggers one infra write, every
* dependent query re-runs with the fresh `getInScopeSpaceIds()`.
*
* Kept intentionally minimal (no encryption, no pending-change
* tracking, not in SYNC_APP_MAP) it's a liveness signal, not user
* data.
*/
import { db } from '../database';
const SCOPE_CURSOR_ID = 'active';
/**
* Write a new `_scopeCursor` row so any in-flight liveQuery that
* touched the table re-runs its querier. Swallows Dexie errors
* this is a best-effort signal, a missing bump only delays the
* re-evaluation until the next Dexie write to a tracked table.
* Safe to call before Dexie finishes opening; the write queues.
*/
export function bumpScopeCursor(): void {
void db
.table('_scopeCursor')
.put({ id: SCOPE_CURSOR_ID, bumpedAt: Date.now() })
.catch((err) => {
console.warn('[scope/cursor] bump failed', err);
});
}
/**
* Register a liveQuery-time subscription on `_scopeCursor`.
* Fire-and-forget: the Dexie read registers the subscription
* synchronously during the querier's execution, even though the
* returned Promise is not awaited. The async resolution itself is
* irrelevant liveQuery only cares that the table was read.
*
* Called from `scopedTable` / `scopedGet` so every scoped query
* automatically subscribes without the caller needing to know.
*/
export function touchScopeCursor(): void {
void db
.table('_scopeCursor')
.get(SCOPE_CURSOR_ID)
.catch(() => undefined);
}

View file

@ -24,6 +24,7 @@ import { getActiveSpaceId } from './active-space.svelte';
import { personalSpaceSentinel } from './bootstrap';
import { isModuleAllowedInSpace, type SpaceModuleId, type SpaceType } from '@mana/shared-types';
import { getActiveSpace } from './active-space.svelte';
import { touchScopeCursor } from './cursor';
export class ScopeNotReadyError extends Error {
constructor() {
@ -75,6 +76,11 @@ export function getInScopeSpaceIds(): string[] {
* first and the spaceId filter runs on the narrowed set.
*/
export function scopedTable<T, PK>(tableName: string): Collection<T, PK> {
// Register a liveQuery-time subscription on `_scopeCursor` so that
// setActiveSpace / setCurrentUserId changes trigger a re-run of this
// query. See data/scope/cursor.ts for the full rationale on the Dexie
// bridge for scope state.
touchScopeCursor();
const table = db.table(tableName) as Table<T, PK>;
const ids = getInScopeSpaceIds();
const check = (record: unknown) => {
@ -127,6 +133,7 @@ export function scopedForModule<T, PK>(
* compound queries with `.or()`, `.and()`, `.reverse()` first.
*/
export function scopedAnd<T, PK>(collection: Collection<T, PK>): Collection<T, PK> {
touchScopeCursor();
const ids = getInScopeSpaceIds();
return collection.and((record) => {
const r = record as { spaceId?: unknown };
@ -144,6 +151,10 @@ export function scopedAnd<T, PK>(collection: Collection<T, PK>): Collection<T, P
* is a single field comparison on the one row returned.
*/
export async function scopedGet<T>(tableName: string, id: string | number): Promise<T | undefined> {
// Register the liveQuery-time subscription same as scopedTable — a
// scopedGet inside a liveQuery (e.g. useGarment(id)) needs to re-run
// too when scope changes.
touchScopeCursor();
const record = (await db.table(tableName).get(id)) as T | undefined;
if (!record) return undefined;
const rec = record as { spaceId?: unknown };