The query layer is the reactive bridge between your UI components and the isolated service layer. It adds caching, reactivity, and state management on top of pure service functions.
import { createQueryFactories } from 'wellcrafted/query';
import { queryClient } from './_client';
export const { defineQuery, defineMutation } =
createQueryFactories(queryClient);These factory functions defineQuery and defineMutations take in query options with result query functions, you get two ways to use it:
.options()- Returns query/mutation options to pass intocreateQuery/createMutation. This registers a subscription with TanStack Query..fetch()/.execute()- Imperative fetching/execution without subscriptions. These still go through TanStack Query's cache/mutation cache, so mutations still register and can be debugged in the devtools.
Every operation in the query layer provides two interfaces to match how you want to use it:
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
// Reactive in components - automatic state management
const recordings = createQuery(rpc.recordings.getAllRecordings.options());
// Syncs: recordings.isPending, recordings.data, recordings.error, recordings.isStale automatically
</script>
{#if recordings.isPending}
<div class="spinner">Loading recordings...</div>
{:else if recordings.error}
<div class="error">Error: {recordings.error.message}</div>
{:else if recordings.data}
{#each recordings.data as recording}
<RecordingCard {recording} />
{/each}
{/if}Perfect for when you want the UI to track and synchronize with the query/mutation lifecycle. This provides automatic state management where your components react to loading states, data changes, and errors without manual intervention.
Examples:
- Component data display
- Loading states and spinners
- Automatic re-renders when data changes
- Cache synchronization across components
// Imperative in actions - lightweight and fast
const { data, error } =
await rpc.recordings.deleteRecording.execute(recordingId);
// No observers, no subscriptions, just the resultPerfect for when you don't need the overhead of observers or subscriptions, and when you want to call operations outside of component lifecycle. This avoids having to create mutations first or prop-drill mutation functions down to child components. You can call .execute() directly from anywhere without being constrained by component boundaries.
Examples:
- Event handlers (button clicks, form submissions)
- Sequential operations and workflows
- One-time data fetches
- Performance-critical operations
- Utility functions outside components
The query layer handles runtime dependency injection—dynamically switching service implementations based on user settings. Unlike services which use build-time platform detection, the query layer makes decisions based on reactive variables:
// Simplified example inspired by the actual transcription implementation
async function transcribeBlob(blob: Blob) {
const selectedService =
settings.value['transcription.selectedTranscriptionService'];
switch (selectedService) {
case 'OpenAI':
return services.transcriptions.openai.transcribe(blob, {
apiKey: settings.value['apiKeys.openai'],
model: settings.value['transcription.openai.model'],
});
case 'Groq':
return services.transcriptions.groq.transcribe(blob, {
apiKey: settings.value['apiKeys.groq'],
model: settings.value['transcription.groq.model'],
});
}
}The query layer uses the TanStack Query client to manipulate the cache for optimistic UI. By updating the cache, reactivity automatically kicks in and the UI reflects these changes, giving you instant optimistic updates:
// From recordings mutations
createRecording: defineMutation({
resultMutationFn: async (recording: Recording) => {
const { data, error } = await services.db.createRecording(recording);
if (error) return Err(error);
// Optimistically update cache - UI updates instantly
queryClient.setQueryData(['recordings'], (oldData) => {
if (!oldData) return [recording];
return [...oldData, recording];
});
return Ok(data);
},
});The query layer co-locates three key things in one place: (1) the service call, (2) runtime settings injection based on reactive variables, and (3) cache manipulation (also reactive). This creates a layer that bridges reactivity with services in an intuitive way, and gives developers a consistent place to put this logic—now developers know that all cache manipulation lives in the query folder.
A critical responsibility of the query layer is transforming service-specific errors into WhisperingError types that work seamlessly with our toast notification system. This transformation happens inside resultMutationFn or resultQueryFn, creating a clean boundary between business logic errors and UI presentation.
The error handling follows a clear pattern across three layers:
- Service Layer: Returns domain-specific tagged errors (e.g.,
ManualRecorderServiceError) - Query Layer: Wraps service errors into
WhisperingErrorobjects - UI Layer: Uses
WhisperingErrordirectly without re-wrapping
This pattern ensures consistent error handling and avoids double-wrapping errors.
Services return their own specific error types (e.g., ManualRecorderServiceError, CpalRecorderServiceError), which contain detailed error information. The query layer transforms these into WhisperingError with UI-friendly formatting:
// From manualRecorder.ts - Error transformation in resultMutationFn
startRecording: defineMutation({
resultMutationFn: async ({ toastId }: { toastId: string }) => {
const { data: deviceAcquisitionOutcome, error: startRecordingError } =
await services.manualRecorder.startRecording(recordingSettings, {
sendStatus: (options) =>
notify.loading.execute({ id: toastId, ...options }),
});
// Transform service error to WhisperingError
if (startRecordingError) {
return Err(
WhisperingError({
title: '❌ Failed to start recording',
description: startRecordingError.message, // Use service error message
action: { type: 'more-details', error: startRecordingError },
}),
);
}
return Ok(deviceAcquisitionOutcome);
},
// WhisperingError is now available in onError hook
onError: (error) => {
// error is WhisperingError, ready for toast display
notify.error.execute(error);
},
});-
Service Layer: Returns domain-specific errors (
TaggedErrortypes from WellCrafted)// In manual-recorder.ts type ManualRecorderServiceError = TaggedError<'ManualRecorderServiceError'>;
-
Query Layer: Transforms to
WhisperingErrorinresultMutationFn/resultQueryFnif (serviceError) { return Err( WhisperingError({ title: '❌ User-friendly title', description: serviceError.message, // Preserve detailed message action: { type: 'more-details', error: serviceError }, }), ); }
-
UI Layer: Receives
WhisperingErrorin hooks, perfect for toastsonError: (error) => notify.error.execute(error); // error is WhisperingError
- Separation of Concerns: Services focus on business logic errors, not UI presentation
- Consistent UI: All errors are transformed to a format that toasts understand
- Detailed Context: Original service errors are preserved in the
actionfield - Type Safety: TypeScript knows exactly what error types flow through each layer
- No Double Wrapping: Each error is wrapped exactly once, at the query layer
// From cpalRecorder.ts
getRecorderState: defineQuery({
resultQueryFn: async () => {
const { data: recorderState, error: getRecorderStateError } =
await services.cpalRecorder.getRecorderState();
if (getRecorderStateError) {
// Transform CpalRecorderServiceError → WhisperingError
return Err(
WhisperingError({
title: '❌ Failed to get recorder state',
description: getRecorderStateError.message,
action: { type: 'more-details', error: getRecorderStateError },
}),
);
}
return Ok(recorderState);
},
});// BAD: Don't wrap an already-wrapped WhisperingError
if (getRecorderStateError) {
const whisperingError = WhisperingErr({
title: '❌ Failed to get recorder state',
description: getRecorderStateError.message,
action: { type: 'more-details', error: getRecorderStateError },
});
notify.error.execute({ id: nanoid(), ...whisperingError.error });
return whisperingError;
}// BAD: Query layer should wrap errors, not return raw service errors
getRecorderState: defineQuery({
queryKey: recorderKeys.state,
resultQueryFn: () => services.manualRecorder.getRecorderState(), // Missing error wrapping!
initialData: 'IDLE' as WhisperingRecordingState,
})// GOOD: Query wraps service errors, UI uses them directly
getRecorderState: defineQuery({
resultQueryFn: async () => {
const { data, error } = await services.manualRecorder.getRecorderState();
if (error) {
return Err(WhisperingError({
title: '❌ Failed to get recorder state',
description: error.message,
action: { type: 'more-details', error },
}));
}
return Ok(data);
},
})
// In UI/command layer - use WhisperingError directly
if (error) {
notify.error.execute(error); // No re-wrapping!
}This pattern ensures that:
- Services remain pure and testable with their own error types
- The query layer handles all UI-specific error formatting
- Toast notifications receive properly formatted
WhisperingErrorobjects - Original error context is preserved for debugging
- Errors are wrapped exactly once, avoiding redundant object creation
This application is fully static site generated and client-side only, which gives us a unique architectural advantage: direct access to the TanStack Query client.
Unlike server-side rendered applications where the query client lifecycle is managed by frameworks, our static approach means:
- Direct Query Client Access: We can call
queryClient.fetchQuery()andqueryClient.getMutationCache().build()directly - Imperative Control: No need to go through reactive hooks for one-time operations
- Performance Benefits**: We can build mutations using direct execution rather than creating unnecessary subscribers
- Flexible Interfaces: Both reactive (
.options()) and imperative (.execute(),.fetch()) patterns work seamlessly
This enables our unique dual interface pattern where every query and mutation provides both reactive and imperative APIs.
RPC (Result Procedure Call) is our central namespace that bundles all query operations into one unified interface. Think of it as your app's "API client" that lives in the frontend:
import { rpc } from '$lib/query';
// Everything you can do in the app is available through rpc.*
rpc.recordings.getAllRecordings;
rpc.transcription.transcribeRecording;
rpc.clipboard.copyToClipboard;
// ... and much moreThe notify API demonstrates how the query layer coordinates multiple services:
import { notify } from '$lib/query';
// Shows BOTH a toast (in-app) AND OS notification
await notify.success.execute({
title: 'Recording saved',
description: 'Your recording has been transcribed',
});
// Loading states only show toasts (no OS notification spam)
const loadingId = await notify.loading.execute({
title: 'Processing...',
});
notify.dismiss(loadingId);This showcases the query layer's coordination role:
- Calls the
toastservice for in-app notifications - Calls the
notificationsservice for OS-level alerts - Adds intelligent logic (e.g., skipping OS notifications for loading states)
- Provides a unified API that's easier to use than calling services directly
The name "RPC" is inspired by Remote Procedure Calls, but adapted for our needs:
- Result: Every operation returns a
Result<T, E>type for consistent error handling - Procedure: Each operation is a well-defined procedure (query or mutation)
- Call: You can call these procedures reactively or imperatively
Author's Note: I know, I know... "RPC" traditionally stands for "Remote Procedure Call," but I've reused the acronym to mean because it's fun and technically this builds off the Result type. People are already familiar with the RPC mental model, and honestly it just feels good to write
rpc. Plus, it sounds way cooler than "query namespace" or whatever. 🤷♂️
When you call createMutation(), you're creating a mutation observer that subscribes to reactive state changes. If you don't need the reactive state (like isPending, isError, etc.), you're paying a performance cost for functionality you're not using.
// ❌ createMutation() approach - Creates subscriber
const mutation = createMutation(rpc.recordings.createRecording.options());
// This creates a mutation observer that:
// - Subscribes to state changes
// - Triggers component re-renders
// - Manages reactive state (isPending, isError, etc.)
// - Adds memory overhead
// Then you call it:
mutation.mutate(recording);// ✅ .execute() approach - Direct execution
const { data, error } = await rpc.recordings.createRecording.execute(recording);
// This directly:
// - Executes the mutation immediately
// - Returns a simple Result<T, E>
// - No reactive state management
// - No component subscriptions
// - No memory overhead from observersUse .execute() when:
- Event handlers that just need the result
- Sequential operations in commands/workflows
- You don't need reactive state (isPending, etc.)
- Performance is critical
- Non-component code (services, utilities)
Use createMutation() when:
- You need reactive state for UI feedback
- Loading spinners, disable states, error displays
- The component needs to react to mutation state changes
Instead of importing individual queries from different files:
// ❌ Without RPC (scattered imports)
import { getAllRecordings } from '$lib/query/recordings';
import { transcribeRecording } from '$lib/query/transcription';
import { copyToClipboard } from '$lib/query/clipboard';You get everything through one clean namespace:
// ✅ With RPC (unified interface)
import { rpc } from '$lib/query';
// Now you have intellisense for everything!
rpc.recordings.getAllRecordings;
rpc.transcription.transcribeRecording;
rpc.clipboard.copyToClipboard;RPC provides:
- Unified Import: One import gives you access to everything
- Better DX: IntelliSense shows all available operations organized by domain
- Consistent Interface: Every operation follows the same dual-interface pattern
- Discoverability: Easy to explore what operations are available
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ UI │ --> │ RPC/Query │ --> │ Services │
│ Components │ │ Layer │ │ (Pure) │
└─────────────┘ └─────────────┘ └──────────────┘
↑ │
└────────────────────┘
Reactive Updates
- Services: Pure functions that return
Result<T, E>(never throw) - Query Layer: Uses WellCrafted's factories to wrap service functions
- RPC Namespace: Bundles all queries into one global object for easy access
- UI Components: Choose reactive (
.options()) or imperative (.execute()) based on needs
<!-- From: /routes/+page.svelte -->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
// These queries automatically update when data changes
const recorderState = createQuery(
rpc.manualRecorder.getRecorderState.options,
);
const latestRecording = createQuery(
rpc.recordings.getLatestRecording.options,
);
</script>
{#if $recorderState.data === 'RECORDING'}
<RecordingIndicator />
{/if}
{#if $latestRecording.data}
<RecordingCard recording={$latestRecording.data} />
{/if}// From: /lib/deliverTextToUser.ts
// ✅ Direct execution - no reactive overhead
async function copyToClipboard(text: string) {
const { error } = await rpc.clipboard.copyToClipboard.execute({ text });
if (error) {
// Using the notify API to show both toast and OS notification
await notify.error.execute({
title: 'Error copying to clipboard',
description: error.message,
action: { type: 'more-details', error },
});
}
}
// ❌ Alternative with createMutation (unnecessary overhead)
// const copyMutation = createMutation(rpc.clipboard.copyToClipboard.options());
// copyMutation.mutate({ text }); // Creates observer, manages state we don't need// From: /lib/commands.ts
// ✅ Perfect use case for .execute() - sequential workflow without UI reactivity
async function stopAndTranscribe() {
// Step 1: Stop recording
const { data: blob, error } = await rpc.manualRecorder.stopRecording.execute({
toastId,
});
if (error) {
toast.error({ title: 'Failed to stop recording' });
return;
}
// Step 2: Play sound effect (fire-and-forget)
rpc.sound.playSoundIfEnabled.execute('manual-stop');
// Step 3: Create recording in database
const { data: recording, error: createError } =
await rpc.recordings.createRecording.execute({
blob,
timestamp: new Date(),
transcriptionStatus: 'PENDING',
});
if (createError) return;
// Step 4: Transcribe the recording
await rpc.transcription.transcribeRecording.execute(recording);
}
// ❌ With createMutation (overkill for workflows)
// const stopMutation = createMutation(rpc.manualRecorder.stopRecording.options());
// const createMutation = createMutation(rpc.recordings.createRecording.options());
// Multiple observers created, state managed unnecessarily// From: /routes/(config)/settings/recording/SelectRecordingDevice.svelte
const deviceStrategy = $derived(settings.value['recording.device.strategy']);
// Query automatically re-runs when deviceStrategy changes
const devices = createQuery(rpc.device.getDevices(deviceStrategy).options);// From: /routes/+layout/alwaysOnTop.svelte.ts
const recorderStateQuery = createQuery(() => ({
...rpc.manualRecorder.getRecorderState.options(),
// Only enable this query when in manual recording mode
enabled: settings.value['recording.mode'] === 'manual',
}));
const vadStateQuery = createQuery(() => ({
...rpc.vadRecorder.getVadState.options(),
// Only enable when using voice activity detection
enabled: settings.value['recording.mode'] === 'vad',
}));// Some RPC methods are direct functions, not queries/mutations
if (rpc.transcription.isCurrentlyTranscribing()) {
showTranscribingIndicator();
}<!-- From: /routes/(config)/recordings/+page.svelte -->
<script lang="ts">
const transcribeRecordings = createMutation(
rpc.transcription.transcribeRecordings.options,
);
const deleteRecordings = createMutation(
rpc.recordings.deleteRecordings.options,
);
async function handleBulkAction(selectedIds: string[]) {
if (action === 'transcribe') {
$transcribeRecordings.mutate(selectedIds);
} else if (action === 'delete') {
$deleteRecordings.mutate(selectedIds);
}
}
</script>The easiest way to understand the query layer is to see it in action:
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
// This automatically subscribes to all recordings
const recordingsQuery = createQuery(rpc.recordings.getAllRecordings.options);
</script>
{#if recordingsQuery.isPending}
<p>Loading recordings...</p>
{:else if recordingsQuery.error}
<p>Error: {recordingsQuery.error.message}</p>
{:else if recordingsQuery.data}
{#each recordingsQuery.data as recording}
<RecordingCard {recording} />
{/each}
{/if}Or imperatively in an event handler:
async function handleDelete(id: string) {
const { error } = await rpc.recordings.deleteRecording.execute(id);
if (error) {
notify.error.execute({
title: 'Failed to delete',
description: error.message,
});
}
}WellCrafted handles the Result<T, E> unwrapping, so TanStack Query gets regular values/errors while you keep type safety.
These factory functions (defineQuery and defineMutation) take query options with result functions - functions that return Result<T, E>. From there, you get two ways to use it:
.options()- Returns query/mutation options to pass into TanStack Query hooks.fetch()/.execute()- Direct execution methods
defineQuery - For data fetching:
// Your service returns Result<T, E>
const userQuery = defineQuery({
queryKey: ['users', userId],
resultQueryFn: () => services.getUser(userId), // Returns Result<User, ApiError>
});
// ✅ Reactive interface - creates query observer
const query = createQuery(userQuery.options());
// - Subscribes to state changes
// - Manages loading, error, success states
// - Triggers component re-renders
// ✅ Imperative interface - direct query client usage
const { data, error } = await userQuery.fetch();
// - Calls queryClient.fetchQuery() directly
// - Returns cached data if fresh
// - No reactive overheaddefineMutation - For data modifications:
const createRecording = defineMutation({
mutationKey: ['recordings', 'create'],
resultMutationFn: async (recording) => {
const result = await services.db.createRecording(recording);
if (result.error) return Err(result.error);
// Update cache on success
queryClient.setQueryData(['recordings'], (old) => [
...(old || []),
recording,
]);
return Ok(result.data);
},
});
// ✅ Reactive interface - creates mutation observer
const mutation = createMutation(createRecording.options());
// - Subscribes to mutation state (isPending, isError, etc.)
// - Triggers component re-renders on state changes
// - Useful for loading states and error displays
// ✅ Imperative interface - direct execution
const { error } = await createRecording.execute(recording);
// - Uses queryClient.getMutationCache().build() directly
// - Returns simple Result<T, E>
// - No reactive state management
// - Still goes through mutation cache for debuggingexport const recordings = {
getAllRecordings: defineQuery({
queryKey: ['recordings'],
resultQueryFn: () => services.db.getAllRecordings(),
}),
};getRecordingById: (id: Accessor<string>) =>
defineQuery({
queryKey: ['recordings', id()], // Dynamic key based on ID
resultQueryFn: () => services.db.getRecordingById(id()),
}),createRecording: defineMutation({
mutationKey: ['recordings', 'create'],
resultMutationFn: async (recording: Recording) => {
const result = await services.db.createRecording(recording);
if (result.error) return Err(result.error);
// Optimistically update cache
queryClient.setQueryData(['recordings'], (old) =>
[...(old || []), recording]
);
return Ok(result.data);
},
}),// Transcription uses current settings dynamically
function transcribeBlob(blob: Blob) {
return services.transcription().transcribe(blob, {
outputLanguage: settings.value['transcription.outputLanguage'],
prompt: settings.value['transcription.prompt'],
temperature: settings.value['transcription.temperature'],
});
}transcribeRecording: defineMutation({
resultMutationFn: async (recording) => {
// Step 1: Update status
await recordings.updateRecording.execute({
...recording,
transcriptionStatus: 'TRANSCRIBING',
});
// Step 2: Perform transcription
const { data, error } = await transcribeBlob(recording.blob);
// Step 3: Update with results
await recordings.updateRecording.execute({
...recording,
transcribedText: data,
transcriptionStatus: error ? 'FAILED' : 'DONE',
});
return error ? Err(error) : Ok(data);
},
}),<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { rpc } from '$lib/query';
// This automatically subscribes to updates
const recordings = createQuery(rpc.recordings.getAllRecordings.options());
</script>
{#if recordings.isLoading}
<p>Loading...</p>
{:else if recordings.error}
<p>Error: {recordings.error.message}</p>
{:else}
{#each recordings.data as recording}
<RecordingCard {recording} />
{/each}
{/if}async function handleDelete(recording: Recording) {
const { error } = await rpc.recordings.deleteRecording.execute(recording);
if (error) {
toast.error({
title: 'Failed to delete',
description: error.message,
});
}
}_utils.ts- Core factory functionsindex.ts- Query client setup and unifiedrpcexport- Feature-specific files (e.g.,
recordings.ts,transcription.ts)
Each feature file typically exports an object with:
- Query definitions
- Mutation definitions
- Helper functions
- Utility methods
- Always use Result types - Never throw errors in query/mutation functions
- Choose the right interface for the job:
- Use
.execute()for event handlers, workflows, and performance-critical operations - Use
createMutation()when you need reactive state for UI feedback
- Use
- Keep queries simple - Complex logic belongs in services or orchestration mutations
- Update cache optimistically - Better UX for mutations
- Use proper query keys - Hierarchical and consistent
- Leverage direct client access - Our static architecture enables powerful patterns unavailable in SSR apps
// In component
const recordingsQuery = createQuery(rpc.recordings.getAllRecordings.options);
// In template
{#if recordingsQuery.isPending}Loading...{/if}
{#if recordingsQuery.data}{recordingsQuery.data}{/if}// Define with accessor
const recordingId = () => '123';
const recordingQuery = createQuery(
rpc.recordings.getRecordingById(recordingId).options,
);const deleteRecordingMutation = createMutation(
rpc.recordings.deleteRecording.options,
);
// Trigger mutation
deleteRecordingMutation.mutate(recordingId);// ✅ Queries - uses queryClient.fetchQuery() directly
const { data, error } = await rpc.recordings.getAllRecordings.fetch();
// - Returns cached data if fresh
// - No reactive subscription
// - Perfect for prefetching or one-time fetches
// ✅ Mutations - uses queryClient.getMutationCache().build() directly
const { data, error } = await rpc.recordings.createRecording.execute(recording);
// - Direct execution without mutation observer
// - No reactive state management overhead
// - Ideal for event handlers and workflowsconst { data, error } = await rpc.clipboard.copyToClipboard.execute({ text });
if (error) {
toast.error({
title: 'Failed to copy',
description: error.message,
});
return;
}
// Success path continues...const vadStateQuery = createQuery(() => ({
...rpc.vadRecorder.getVadState.options(),
enabled: settings.value['recording.mode'] === 'vad',
}));defineMutation({
resultMutationFn: async (recording) => {
const result = await services.db.createRecording(recording);
if (result.error) return Err(result.error);
// Update cache
queryClient.setQueryData(['recordings'], (old) => [
...(old || []),
recording,
]);
return Ok(result.data);
},
});// Query layer automatically uses current settings
function transcribeBlob(blob: Blob) {
return services.transcription().transcribe(blob, {
outputLanguage: settings.value['transcription.outputLanguage'],
temperature: settings.value['transcription.temperature'],
});
}Understanding how RPC fits into the bigger picture:
Pure functions that do the actual work:
// services/db.ts
export async function getAllRecordings(): Promise<
Result<Recording[], DbError>
> {
try {
const recordings = await database.recordings.findMany();
return Ok(recordings);
} catch (error) {
return Err(new DbError('Failed to fetch recordings'));
}
}Wraps services with reactivity and caching:
// query/recordings.ts
export const recordings = {
getAllRecordings: defineQuery({
queryKey: ['recordings'],
resultQueryFn: () => services.db.getAllRecordings(), // Calls the service
}),
};Bundles everything for easy access:
// query/index.ts
export const rpc = {
recordings, // Contains getAllRecordings and other recording operations
transcription, // Contains transcribe and other transcription operations
// ... all other feature modules
};Use RPC in your components:
<script>
import { rpc } from '$lib/query';
// Reactive usage
const recordingsQuery = createQuery(rpc.recordings.getAllRecordings.options);
// Imperative usage
async function deleteRecording(id) {
const { error } = await rpc.recordings.deleteRecording.execute(id);
}
</script>When you need to add new functionality:
- Create a service in
/serviceswith pure business logic - Create a query wrapper in
/querythat adds:- TanStack Query integration
- Cache management
- Settings reactivity
- Error handling
- Export from RPC in
index.tsso it's available globally - Use in components via either reactive or imperative patterns
This keeps everything organized and testable while giving you a unified way to access all app functionality.