mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
3f438cf882
commit
d8feef1149
5 changed files with 115 additions and 4 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
61
apps/mana/apps/web/src/lib/data/scope/cursor.ts
Normal file
61
apps/mana/apps/web/src/lib/data/scope/cursor.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue