diff --git a/apps/mana/apps/web/src/lib/data/current-user.ts b/apps/mana/apps/web/src/lib/data/current-user.ts index 4c965a8f0..e018d1249 100644 --- a/apps/mana/apps/web/src/lib/data/current-user.ts +++ b/apps/mana/apps/web/src/lib/data/current-user.ts @@ -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. */ diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index db551a2cf..a5c267105 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index ee14c03a2..2448fa9df 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -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 { + 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); +} diff --git a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts index 5ced28151..f7b0dbc4b 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -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(tableName: string): Collection { + // 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; const ids = getInScopeSpaceIds(); const check = (record: unknown) => { @@ -127,6 +133,7 @@ export function scopedForModule( * compound queries with `.or()`, `.and()`, `.reverse()` first. */ export function scopedAnd(collection: Collection): Collection { + touchScopeCursor(); const ids = getInScopeSpaceIds(); return collection.and((record) => { const r = record as { spaceId?: unknown }; @@ -144,6 +151,10 @@ export function scopedAnd(collection: Collection): Collection(tableName: string, id: string | number): Promise { + // 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 };