fix(mana/web): migrate liveQuery hooks to useLiveQueryWithDefault

Seven module query files were calling raw `liveQuery(async () => ...)`
from dexie and returning the resulting Observable<T>. Consumer code in
the route .svelte files then read `.value` (or `.current`) on those
observables, which doesn't exist on the Dexie type — TypeScript flagged
38 errors and the call sites were silently relying on a runtime
property that only happens to work because the Svelte reactivity layer
re-evaluates the access.

Migration: switch each `useXxx()` hook to wrap with the existing
`useLiveQueryWithDefault` from `@mana/local-store/svelte`. The wrapper
returns `{ value, loading, error }` (with `value` synced to a `$state`
under the hood), so call sites can read `.value` reactively without
casts. Each hook now provides a typed default array so the wrapper
infers the right shape on first render.

Modules migrated:
  - chat        — useAllConversations, useArchivedConversations,
                  useAllTemplates, useConversationMessages
  - citycorners — useAllCities, useAllLocations, useAllFavorites
  - memoro      — useAllMemos, useArchivedMemos, useMemoriesByMemo,
                  useAllMemoTags, useAllSpaces
  - nutriphi    — useAllMeals, useAllGoals, useAllFavorites
  - presi       — useAllDecks, useDeckSlides, useDeck
  - questions   — useAllCollections, useAllQuestions,
                  useAnswersByQuestion
  - skilltree   — useAllSkills, useAllActivities, useAllAchievements

Call sites cleaned up:
  - chat/[id], memoro/[id]: removed inline `as { value: T[] }` casts
    that were the workaround for the broken type
  - nutriphi/{,add,goals,history}/+page.svelte: `.current ?? []` →
    `.value` (the wrapper guarantees the default array, so the
    nullish coalesce was always dead)
  - questions/{,[id],new,collections}/+page.svelte: same `.current` →
    `.value` migration

Net: -38 type errors, no behavior change. The wrappers continue to
subscribe to the same Dexie liveQuery under the hood; only the
ergonomic surface changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 18:12:15 +02:00
parent 0426b6677b
commit ff6118fc3b
16 changed files with 82 additions and 74 deletions

View file

@ -7,7 +7,7 @@
* to the public types so consumers see plaintext.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
@ -68,18 +68,18 @@ export function toMessage(local: LocalMessage): Message {
/** All non-archived conversations, sorted by pinned first then updatedAt desc. */
export function useAllConversations() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalConversation>('conversations').toArray()).filter(
(c) => !c.deletedAt && !c.isArchived
);
const decrypted = await decryptRecords('conversations', visible);
return sortConversations(decrypted.map(toConversation));
});
}, [] as Conversation[]);
}
/** All archived conversations, sorted by updatedAt desc. */
export function useArchivedConversations() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalConversation>('conversations').toArray()).filter(
(c) => !c.deletedAt && c.isArchived
);
@ -87,23 +87,23 @@ export function useArchivedConversations() {
return decrypted
.map(toConversation)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
}, [] as Conversation[]);
}
/** All templates, sorted by name. */
export function useAllTemplates() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalTemplate>('chatTemplates').toArray()).filter(
(t) => !t.deletedAt
);
const decrypted = await decryptRecords('chatTemplates', visible);
return decrypted.map(toTemplate).sort((a, b) => a.name.localeCompare(b.name));
});
}, [] as Template[]);
}
/** Messages for a specific conversation, sorted by createdAt asc. */
export function useConversationMessages(conversationId: string) {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (
await db
.table<LocalMessage>('messages')
@ -115,7 +115,7 @@ export function useConversationMessages(conversationId: string) {
return decrypted
.map(toMessage)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
});
}, [] as Message[]);
}
// ─── Pure Sort / Filter Functions (for $derived) ───────────

View file

@ -5,34 +5,39 @@
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalCity, LocalLocation, LocalFavorite } from './types';
// ─── Live Query Hooks ─────────────────────────────────────
//
// Each hook returns `{ value, loading, error }` — call sites read
// `.value` reactively (the wrapper internally manages a `$state`
// snapshot synced to the underlying Dexie liveQuery). MUST be called
// from inside a component setup, never from a module-level constant.
/** All cities, sorted by name. Auto-updates on any change. */
export function useAllCities() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const all = await db.table<LocalCity>('cities').toArray();
return all.filter((c) => !c.deletedAt).sort((a, b) => a.name.localeCompare(b.name));
});
}, [] as LocalCity[]);
}
/** All locations, sorted by name. Auto-updates on any change. */
export function useAllLocations() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const all = await db.table<LocalLocation>('ccLocations').toArray();
return all.filter((l) => !l.deletedAt).sort((a, b) => a.name.localeCompare(b.name));
});
}, [] as LocalLocation[]);
}
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFavorite>('ccFavorites').toArray();
return all.filter((f) => !f.deletedAt);
});
}, [] as LocalFavorite[]);
}
// ─── Pure Filter Functions (for $derived) ───────────────────

View file

@ -2,7 +2,7 @@
* Reactive queries & pure helpers for Memoro uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
@ -60,18 +60,18 @@ export function toSpace(local: LocalSpace): Space {
/** All non-archived memos, sorted by pinned first then createdAt desc. */
export function useAllMemos() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalMemo>('memos').toArray()).filter(
(m) => !m.deletedAt && !m.isArchived
);
const decrypted = await decryptRecords('memos', visible);
return sortMemos(decrypted.map(toMemo));
});
}, [] as Memo[]);
}
/** All archived memos, sorted by updatedAt desc. */
export function useArchivedMemos() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalMemo>('memos').toArray()).filter(
(m) => !m.deletedAt && m.isArchived
);
@ -79,18 +79,18 @@ export function useArchivedMemos() {
return decrypted
.map(toMemo)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
}, [] as Memo[]);
}
/** Memories for a specific memo. */
export function useMemoriesByMemo(memoId: string) {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (
await db.table<LocalMemory>('memories').where('memoId').equals(memoId).toArray()
).filter((m) => !m.deletedAt);
const decrypted = await decryptRecords('memories', visible);
return decrypted.map(toMemory);
});
}, [] as Memory[]);
}
// Tags: use shared global tags from @mana/shared-stores
@ -98,18 +98,18 @@ export { useAllTags } from '@mana/shared-stores';
/** All memo-tag associations. */
export function useAllMemoTags() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalMemoTag>('memoTags').toArray();
return locals.filter((mt) => !mt.deletedAt);
});
}, [] as LocalMemoTag[]);
}
/** All spaces. */
export function useAllSpaces() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSpace>('memoroSpaces').toArray();
return locals.filter((s) => !s.deletedAt).map(toSpace);
});
}, [] as Space[]);
}
// ─── Pure Sort / Filter Functions ──────────────────────────

View file

@ -4,7 +4,7 @@
* Uses table names: meals, goals, nutriFavorites.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
@ -42,12 +42,12 @@ export function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
/** All meals, auto-updates on any change. */
export function useAllMeals() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalMeal>('meals').toArray();
const visible = locals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords('meals', visible);
return decrypted.map(toMealWithNutrition);
});
}, [] as MealWithNutrition[]);
}
/**
@ -64,18 +64,18 @@ export async function loadMealById(id: string): Promise<MealWithNutrition | null
/** All goals, auto-updates on any change. */
export function useAllGoals() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalGoal>('goals').toArray();
return locals.filter((g) => !g.deletedAt);
});
}, [] as LocalGoal[]);
}
/** All favorites, auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalFavorite>('nutriFavorites').toArray();
return locals.filter((f) => !f.deletedAt);
});
}, [] as LocalFavorite[]);
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────

View file

@ -4,7 +4,7 @@
* Uses prefixed table names: presiDecks, slides.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
import type { LocalDeck, LocalSlide, Deck, Slide } from './types';
@ -39,34 +39,37 @@ export function toSlide(local: LocalSlide): Slide {
/** All decks, sorted by updatedAt descending. Auto-updates on any change. */
export function useAllDecks() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalDeck>('presiDecks').toArray()).filter((d) => !d.deletedAt);
const decrypted = await decryptRecords('presiDecks', visible);
return decrypted
.map(toDeck)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
}, [] as Deck[]);
}
/** Slides for a specific deck, sorted by order. Auto-updates on any change. */
export function useDeckSlides(deckId: string) {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const visible = (
await db.table<LocalSlide>('slides').where('deckId').equals(deckId).toArray()
).filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('slides', visible);
return decrypted.map(toSlide).sort((a, b) => a.order - b.order);
});
}, [] as Slide[]);
}
/** Single deck by ID. Auto-updates on any change. */
export function useDeck(id: string) {
return liveQuery(async () => {
const local = await db.table<LocalDeck>('presiDecks').get(id);
if (!local || local.deletedAt) return null;
const decrypted = await decryptRecord('presiDecks', { ...local });
return toDeck(decrypted);
});
return useLiveQueryWithDefault(
async () => {
const local = await db.table<LocalDeck>('presiDecks').get(id);
if (!local || local.deletedAt) return null;
const decrypted = await decryptRecord('presiDecks', { ...local });
return toDeck(decrypted);
},
null as Deck | null
);
}
// ─── Pure Helper Functions ────────────────────────────────

View file

@ -4,7 +4,7 @@
* Uses table names: qCollections, questions, answers.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalCollection, LocalQuestion, LocalAnswer } from './types';
@ -102,33 +102,33 @@ export function toAnswer(local: LocalAnswer): Answer {
/** All collections, sorted by sortOrder. Auto-updates on any change. */
export function useAllCollections() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCollection>('qCollections').toArray();
return locals
.filter((c) => !c.deletedAt)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(toCollection);
});
}, [] as Collection[]);
}
/** All questions. Auto-updates on any change. */
export function useAllQuestions() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalQuestion>('questions').toArray();
const visible = locals.filter((q) => !q.deletedAt);
const decrypted = await decryptRecords('questions', visible);
return decrypted.map(toQuestion);
});
}, [] as Question[]);
}
/** All answers for a given question. */
export function useAnswersByQuestion(questionId: string) {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalAnswer>('answers').toArray();
const visible = locals.filter((a) => !a.deletedAt && a.questionId === questionId);
const decrypted = await decryptRecords('answers', visible);
return decrypted.map(toAnswer);
});
}, [] as Answer[]);
}
// ─── Pure Filter Functions (for $derived) ───────────────────

View file

@ -6,7 +6,7 @@
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalSkill, LocalActivity, LocalAchievement } from './types';
import type { Skill, Activity, SkillBranch, UserStats } from './types';
@ -46,26 +46,26 @@ export function toActivity(local: LocalActivity): Activity {
/** All skills, auto-updates on any change. */
export function useAllSkills() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSkill>('skills').toArray();
return locals.filter((s) => !s.deletedAt).map(toSkill);
});
}, [] as Skill[]);
}
/** All activities, auto-updates on any change. */
export function useAllActivities() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalActivity>('activities').toArray();
return locals.filter((a) => !a.deletedAt).map(toActivity);
});
}, [] as Activity[]);
}
/** All achievements (raw local records), auto-updates on any change. */
export function useAllAchievements() {
return liveQuery(async () => {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalAchievement>('achievements').toArray();
return locals.filter((a) => !a.deletedAt);
});
}, [] as LocalAchievement[]);
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────

View file

@ -25,7 +25,7 @@
// Live query for messages of this conversation
const messagesQuery = $derived(useConversationMessages(conversationId));
let messages = $derived((messagesQuery as { value: Message[] })?.value ?? []);
let messages = $derived(messagesQuery.value);
let inputText = $state('');
let isSending = $state(false);

View file

@ -33,7 +33,7 @@
// Live query for memories of this memo
const memoriesQuery = $derived(useMemoriesByMemo(memoId));
let memories = $derived((memoriesQuery as { value: Memory[] })?.value ?? []);
let memories = $derived(memoriesQuery.value);
let memoTags = $derived(getTagsForMemo(tagsCtx.value, memoTagsCtx.value, memoId));

View file

@ -16,8 +16,8 @@
const allMeals = useAllMeals();
const allGoals = useAllGoals();
let meals = $derived(allMeals.current ?? []);
let goals = $derived((allGoals.current ?? [])[0] ?? null);
let meals = $derived(allMeals.value);
let goals = $derived(allGoals.value[0] ?? null);
let todaysMeals = $derived(getTodaysMeals(meals));
let dailySummary = $derived(getDailySummary(meals, undefined, goals));

View file

@ -12,7 +12,7 @@
import { ArrowLeft } from '@mana/shared-icons';
const allFavorites = useAllFavorites();
let favorites = $derived(allFavorites.current ?? []);
let favorites = $derived(allFavorites.value);
type Mode = 'text' | 'photo';
let mode = $state<Mode>('text');

View file

@ -15,8 +15,8 @@
const allMeals = useAllMeals();
const allGoals = useAllGoals();
let meals = $derived(allMeals.current ?? []);
let goals = $derived((allGoals.current ?? [])[0] ?? null);
let meals = $derived(allMeals.value);
let goals = $derived(allGoals.value[0] ?? null);
let searchQuery = $state('');
let selectedDate = $state('');

View file

@ -17,14 +17,14 @@
let selectedCollectionId = $state<string | null>(null);
let filteredQuestions = $derived.by(() => {
let result = allQuestions.current ?? [];
let result = allQuestions.value;
result = filterByCollection(result, selectedCollectionId);
if (statusFilter) result = filterByStatus(result, statusFilter);
if (searchQuery) result = searchQuestions(result, searchQuery);
return result;
});
let collections = $derived(allCollections.current ?? []);
let collections = $derived(allCollections.value);
let selectedCollection = $derived(
selectedCollectionId ? collections.find((c) => c.id === selectedCollectionId) : null

View file

@ -28,12 +28,12 @@
const allQuestions = useAllQuestions();
let questionId = $derived($page.params.id);
let question = $derived(getQuestionById(allQuestions.current ?? [], questionId));
let questionId = $derived($page.params.id ?? '');
let question = $derived(getQuestionById(allQuestions.value, questionId));
// Answers live query — we call it reactively via the id
let answersQuery = $derived(useAnswersByQuestion(questionId));
let answers = $derived(answersQuery?.current ?? []);
let answers = $derived(answersQuery.value);
let editing = $state(false);
let editTitle = $state('');

View file

@ -13,8 +13,8 @@
const allCollections = useAllCollections();
const allQuestions = useAllQuestions();
let collections = $derived(allCollections.current ?? []);
let questions = $derived(allQuestions.current ?? []);
let collections = $derived(allCollections.value);
let questions = $derived(allQuestions.value);
let showModal = $state(false);
let editingCollection = $state<Collection | null>(null);

View file

@ -8,7 +8,7 @@
import { ArrowLeft, Lightning, Clock, Sparkle } from '@mana/shared-icons';
const allCollections = useAllCollections();
let collections = $derived(allCollections.current ?? []);
let collections = $derived(allCollections.value);
let title = $state('');
let description = $state('');