Skip to content

feat(whispering): complete workspace state migration — replace TanStack Query with reactive $state#1546

Merged
braden-w merged 40 commits intomainfrom
opencode/silent-squid
Mar 20, 2026
Merged

feat(whispering): complete workspace state migration — replace TanStack Query with reactive $state#1546
braden-w merged 40 commits intomainfrom
opencode/silent-squid

Conversation

@braden-w
Copy link
Member

@braden-w braden-w commented Mar 20, 2026

This finishes the migration started in #1534, replacing every TanStack Query read and mutation in the UI with direct workspace $state modules — because keeping a caching layer over data that's already in memory was pointless overhead.

The pattern is consistent across all five domain areas (recordings, transformations, steps, runs, step-runs):

BEFORE                                    AFTER
────────────────────────────              ────────────────────────────
createQuery(rpc.recordings)    →          workspace.recordings.sorted
createMutation(rpc.update)     →          workspace.tables.recordings.set(row)
invalidateQueries(['recordings'])  →      (automatic — $state is reactive)

The result is simpler data flow: components read reactive getters backed by SvelteMap over the Y.Doc, and write directly to workspace tables. No query keys, no cache invalidation, no loading states for local data.

The work broke down into four phases. First, $state-backed modules for each domain area — each wrapping a workspace table with sorted getters, lookup helpers, and domain-specific derived state. Then the component migration itself: Editor, RecordingRowActions, Test.svelte, the transformer pipeline — all switched over. After that, dead code removal: invalidateQueries calls, unused RPC imports, a duplicated provider switch in handleStep, and a rename of db.ts to audio.ts (after the migration, the only thing left in that file was getPlaybackUrl, so the name was just wrong). Finally, a cleanup pass dropped the workspace prefix from state filenames (workspaceRecordings.svelte.tsrecordings.svelte.ts) and updated all docs, READMEs, and skills to match.

Three standalone fixes came out of testing: sidebar button centering in the collapsed rail, a confirmation dialog that was closing even when the operation errored, and a recording ID teardown crash.

Why not keep TanStack Query for local reads? It adds a caching layer over data that's already in memory. The workspace tables are the cache. TanStack Query's value is remote data fetching, not local CRDT state.

Why $state over $derived for sorted arrays? Referential stability. $derived with Array.from().sort() creates a new array on every access, which causes infinite loops with TanStack Table (see docs/skills/svelte for the full write-up). $state with explicit refresh gives stable references.

Why rename db.tsaudio.ts? After the migration, the only thing left in db.ts was getPlaybackUrl() for audio blob retrieval. The name was misleading.

Changelog

  • Replace all TanStack Query reads and mutations with reactive workspace state
  • Fix sidebar button centering in collapsed rail
  • Fix confirmation dialog always closing even on error
  • Fix recording ID capture to prevent teardown crash

braden-w added 30 commits March 15, 2026 01:11
…mations, and steps

Phase 1 of query layer switch: create SvelteMap + Yjs observer state
modules that replace TanStack Query for workspace table reads/writes.

- workspace-recordings.svelte.ts: SvelteMap<id, Recording> with sorted getter
- workspace-transformations.svelte.ts: SvelteMap<id, Transformation>
- workspace-transformation-steps.svelte.ts: SvelteMap<id, Step> with getByTransformationId
…pace state

Phase 2 of query layer switch: replace createQuery reads with direct
SvelteMap access from workspace state modules.

- recordings/+page.svelte: workspaceRecordings.sorted replaces getAll query
- RecordingRowActions: workspaceRecordings.get() replaces getById query
- Home page: workspaceRecordings.sorted[0] replaces getLatest query
- transformations/+page.svelte: workspaceTransformations.sorted replaces getAll
- TransformationSelector: workspaceTransformations.sorted replaces getAll
- Removed loading skeletons (SvelteMap has no pending state)
…workspace state

Phase 3 of query layer switch: replace mutation calls with direct workspace
writes. Workspace deletes are fire-and-forget via Yjs—no error handling needed.

Recordings:
- processRecordingPipeline: metadata to workspace, audio blob to DbService
- transcription.ts: 3 update calls → workspaceRecordings.update()
- recording-actions, recordings page, home page: delete → workspace + revokeAudioUrl

Transformations:
- Deletes switched (page bulk delete, TransformationRowActions)
- Create/update deferred (Editor uses dot-notation field names incompatible
  with workspace flat schema—requires separate follow-up refactor)
…ing read in transformer

Phase 4 of query layer switch: transformation runs (incremental).

- Created workspace-transformation-runs.svelte.ts with getByTransformationId/getByRecordingId
- Switched recording lookup in transformer.ts from DbService to workspaceRecordings.get()
- Kept DbService run queries as fallback for historical runs
- Deferred full run lifecycle wiring (complex step-by-step execution)
Phase 5 of query layer switch: additional cleanup and documentation.

- EditRecordingModal: update mutation → workspaceRecordings.set()
- TransformationPickerBody: TanStack Query → workspaceTransformations
- EditTransformationModal: delete → workspaceTransformations.delete()
- state/README.md: documented all 4 new workspace state modules
- Spec updated to Partially Implemented with review section noting
  deferred work (Editor flat field refactor, full db.ts cleanup)
…space writes

Complete the transformation create/update migration to workspace state.

Editor components:
- Configuration.svelte: all dot-notation fields → flat workspace names
  (e.g., 'prompt_transform.inference.provider' → inferenceProvider)
- Editor.svelte: receives transformation + steps as separate props
- Test.svelte: uses steps prop for length check
- Runs loaded from workspace state instead of TanStack Query

Parent components:
- EditTransformationModal: workspace.batch() for atomic save (metadata + steps)
- CreateTransformationButton: workspace.batch() for create
- new/+page.svelte: same pattern

transformer.ts:
- handleStep() uses flat field names
- runTransformation() accepts steps parameter
- transformRecording gets steps from workspaceTransformationSteps
…ale type imports

- RecordingRowActions: re-add createQuery import (runs query still uses TanStack Query)
- MarkTransformationActiveButton: Transformation type from workspace state
- download.ts: Recording type from workspace state
…nite render loop

The sorted getter was returning a new array on every access. TanStack Table's
createSvelteTable wraps options in $derived — new array reference triggers
internal $state update → invalidates the outer $derived → re-calls sorted →
new array again → infinite loop → page freeze.

Fix: compute sorted arrays via $derived so the reference is stable until
the underlying SvelteMap actually changes.
… 8 inline Omit usages

Export TransformationStepDraft = Omit<TransformationStep, '_v'> from the
state module so consumers don't repeat the version-tag omission everywhere.
Pure type change—zero runtime impact.
… replace 8 inline Omit usages"

This reverts commit 2fa316a.
…h workspace state

latestTransformationRunByRecordingIdQuery → workspaceTransformationRuns.getLatestByRecordingId()

Eliminates createQuery import entirely from this component.
Removes loading spinner and error tooltip (data is always available from SvelteMap).
…Omit<_, '_v'> indirection

Include _v in generateDefaultStep() and accept full TransformationStep in
set(). All component props now use TransformationStep[] directly instead of
Omit<TransformationStep, '_v'>[]—the version tag is an implementation detail
that should flow through, not be stripped at every boundary.
Documents the SvelteMap + TanStack Table infinite loop pattern and fix:
plain getters that return new arrays cause  →  →
loops. Fix: memoize with $derived for stable references.
… of aliasing

Replace individual const assignments (step.findText, step.inferenceProvider,
etc.) with destructuring at the top of each case branch. Cosmetic only—no
behavior change.
…module

Move the duplicated function from CreateTransformationButton and new/+page
into workspace-transformations.svelte.ts. Also fix set() to accept full
Transformation (same _v pattern as the steps module).
The mutation expects { input, transformation, steps } but Test.svelte was
only passing { input, transformation }. Steps were received as a prop but
never forwarded, causing the transformation test runner to receive undefined
steps at runtime.
Replace three duplicated workspace.batch() save patterns with a single
shared function. Works for both create and update—deleteByTransformationId
is a no-op on fresh transformations, and updatedAt is always refreshed.
…kspace state

- LatestTransformationRunOutputByRecordingId: createQuery → workspaceTransformationRuns
- ViewTransformationRunsDialog: createQuery → workspaceTransformationRuns
- Runs.svelte: rpc.db.runs.delete → workspaceTransformationRuns.delete

rpc.db now only has 3 references left, all for audio blob playback URLs
(getAudioPlaybackUrl) — binary data that's too large for Yjs CRDTs.
…config map

Replace 6 nearly identical provider cases (~90 lines) with a
STANDARD_PROVIDER_CONFIG lookup (~15 lines). Custom is kept inline since it
has unique per-step baseUrl logic. Adding a new standard provider is now a
one-line config entry instead of a copy-pasted case block.

Also fix interpolateTemplate systemPrompt call that was missing { input }.
…s from transformer.ts

All 5 invalidateQueries calls used dbKeys.runs.* and dbKeys.transformations.*
which no component subscribes to after the workspace state migration.
Also removes the now-unused queryClient and dbKeys imports.
The entire db query module (345 lines of CRUD queries, mutations, and
cache management) is dead after migrating to Yjs workspace state modules.
Only getAudioPlaybackUrl survives—audio blobs are too large for CRDTs.

Renames the module, export, and all 3 consumer call sites:
  rpc.db.recordings.getAudioPlaybackUrl → rpc.audio.getPlaybackUrl
The Three Layers section now shows workspace state as the primary data
layer for recordings, transformations, and runs. Query Layer vs State
section explains what lives where: CRDTs for domain data, TanStack
Query for external APIs, hardware state, and audio blob access.
Skill file examples now use live APIs (rpc.recorder, rpc.transformer)
instead of dead rpc.db.recordings. Accessor pattern article gets a
staleness note since its examples use the removed API.
…ation

Replace dead optimistic update example in ARCHITECTURE.md with current
workspace state pattern. Fix stale inline comment in EditRecordingModal.
Document audit findings in cleanup spec.
Rename all 5 workspace-backed state modules to remove the redundant
workspace- prefix from filenames and workspace prefix from export names.
The state/ directory is the namespace—consumers don't need storage-backend
information at every call site.

File renames:
  workspace-recordings.svelte.ts → recordings.svelte.ts
  workspace-settings.svelte.ts → settings.svelte.ts
  workspace-transformations.svelte.ts → transformations.svelte.ts
  workspace-transformation-steps.svelte.ts → transformation-steps.svelte.ts
  workspace-transformation-runs.svelte.ts → transformation-runs.svelte.ts

Export renames:
  workspaceRecordings → recordings
  workspaceSettings → settings
  workspaceTransformations → transformations
  workspaceTransformationSteps → transformationSteps
  workspaceTransformationRuns → transformationRuns

Resolves 2 local variable collisions in TransformationSelector and
TransformationPickerBody by renaming const transformations to
sortedTransformations.
Update ARCHITECTURE.md, state README, query README, settings README,
and 2 docs/articles to use the new unprefixed names (recordings,
settings, transformations, etc.) in all code examples and prose.
…query README straggler

The recordings parameter in deleteWithConfirmation shadowed the imported
recordings module after the rename, causing .delete() to call on the
parameter instead of the state module. Renamed parameter to toDelete.

Also fixed a missed old reference in query/README.md line 1071.
Archive the original query layer README as a standalone article
preserving the co-location philosophy, optimistic updates patterns,
and dual-interface teaching examples from before workspace migration.

Also fix 4 remaining stale workspaceRecordings imports in the bottom
sections that were missed by the state flatten rename.
Check off all items, add review section documenting 3 deviations:
additional collision in recording-actions.ts, 51 files instead of 48,
and typecheck script name correction.
… examples

The Common Patterns #3 code examples still used workspaceRecordings
after the state flatten rename. Now matches the actual transcription.ts
source which uses recordings.update().
…cles

Accessor pattern article: rpc.recordings.get(id) → rpc.audio.getPlaybackUrl(id)
(the only current parameterized query in the codebase).

Mutation callbacks pattern: rpc.clipboard.copyToClipboard → rpc.text.copyToClipboard.
During deletion, Svelte's reactive teardown races with prop updates—the
recording prop becomes undefined before onDestroy fires, throwing a
TypeError that blocks the confirmation dialog from closing. Capturing
the ID at mount time sidesteps the reactive teardown race entirely.
…ys closes

The sync branch of confirm() had no error handling—if onConfirm threw
(e.g. from a reactive teardown during deletion), isOpen was never set
to false and the dialog stayed open. Now uses try/finally to guarantee
the dialog closes regardless of what onConfirm does, matching the
resilience the async branch already had.
…tress testing

Provides Y.Doc size monitoring, per-table row counts, and bulk
recording generation/deletion with timing results. Helps developers
understand storage costs and stress-test at scale without leaving
the app.

Uses factory function pattern (createMetrics, createStressTest) to
co-locate reactive state with its operations. Dev-only via
import.meta.env.DEV guard.
… nav link

Remove onComplete callback coupling between createStressTest and
createMetrics—the template now calls both explicitly. Remove debug
nav link from NavItems since it only renders in nav-items layout
mode; /debug is accessible via URL directly.
# Conflicts:
#	.agents/skills/svelte/SKILL.md
#	apps/whispering/src/lib/query/index.ts
@github-actions
Copy link
Contributor

github-actions bot commented Mar 20, 2026

Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement. This is a one-time requirement—it lets us offer commercial licenses for the sync server while you retain full copyright on your code.

To sign, please comment on this PR with:

I have read the CLA Document and I hereby sign the CLA


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

query-layer: drop inline cache management section (moved to
references/advanced-query-patterns.md on main).
svelte: keep both our reactive table state patterns and main's
prop-first data derivation section.
@braden-w braden-w merged commit f027287 into main Mar 20, 2026
1 of 10 checks passed
@braden-w braden-w deleted the opencode/silent-squid branch March 20, 2026 02:14
@github-actions github-actions bot locked and limited conversation to collaborators Mar 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant