Skip to content

feat: migrate to Effect 4 and Solid 2 RC - #48

Open
joncrangle wants to merge 11 commits into
mainfrom
codex/effect-solid2-migration
Open

joncrangle wants to merge 11 commits into
mainfrom
codex/effect-solid2-migration

Conversation

@joncrangle

Copy link
Copy Markdown
Owner

Summary

  • Migrate Effect 3 to Effect 4 RC APIs and services.
  • Migrate Solid 1 APIs to Solid 2 RC APIs without rendered effects in application code.
  • Add the temporary OpenTUI 0.5.11 compatibility patch required by the current Solid 1-only renderer.
  • Fix stale drive-selection errors and the live file-handle lifetime bug causing SystemError: Bad file descriptor.
  • Add runtime, filesystem, and drive-selection regression coverage.

Validation

  • bun install --frozen-lockfile
  • just check
  • just test — 149 passing
  • just lint
  • just build
  • TUI smoke test completed without targeted runtime errors

Merge gates

This PR is intentionally a migration branch and should remain unmerged until:

  1. Solid 2 is released out of RC.
  2. Effect 4 is released out of RC.
  3. OpenTUI officially supports Solid 2.x.
  4. The OpenTUI compatibility patch can be removed and the application still passes a clean install, tests, build, and smoke test.

The OpenTUI packages are pinned to 0.5.11 while the patch is present to prevent version drift.

@joncrangle
joncrangle force-pushed the codex/effect-solid2-migration branch from 13346d2 to 1a71568 Compare September 18, 2026 23:20
Remove unused DriveItem component and importer-less components barrel. Trim AppView union to the 7 live views and collapse the footer switch. Remove production-unused isNavigationKey and its tests. Fix dead Header padding ternary. Trim AGENTS.md to evergreen guidance. Add just fmt-check and enforce it in CI.
@joncrangle

Copy link
Copy Markdown
Owner Author

Merge-gate landing checklist

When Solid 2 / Effect 4 go stable and OpenTUI ships a Solid 2-native renderer, this is exactly what needs to change (verified 2026-09-20 — all three are still RC upstream, so nothing to do yet):

  1. package.json — move solid-js off the exact RC pin to a stable range; move effect off the RC to stable; bump @opentui/solid + @opentui/core to the Solid 2-native release. Then bun install to regenerate bun.lock.
  2. Delete patches/@opentui%2Fsolid@0.5.11.patch and remove the patchedDependencies entry. Each shim is documented inline in the patch (mergeProps/splitProps adapters, createRenderEffect adapter, onMount→onSettled swaps, ErrorBoundary→Errored, RendererContext change) — verify the native renderer covers each one.
  3. Revisit src/__tests__/opentui_runtime.test.ts — it validates the patched behavior (settle-phase cleanup, render-phase Portal cleanup). The testRender import surface may move with the native renderer.
  4. Bump @effect/language-service together with Effect, plus its tsconfig.json plugin entry — they are version-coupled.
  5. No change needed: onSettled/Errored in src/ (index.tsx, Spinner.tsx, useAppLogic.ts) are native Solid 2 APIs (frozen at RC), and src/ has zero Solid 1 sites left.

Seven exports were declared but referenced nowhere in the repo:

- PaneId (superseded by FocusedPane in types/keyboard.ts)
- SyncOptions, SyncEpisode, SyncResult
- DriveEpisode, DrivePodcast (a self-contained dead pair; DriveScan
  declares its own inline shape for buildDriveIndex)
- createPodcastServiceTest (siblings createSyncEngineTest,
  createDriveScanTest, createMetadataEditorTest and createFileSystemTest
  are all still in use)
Three independent bugs in the sync path.

Sync success messages were silently discarded. startSync returns a
message on both success paths ("All episodes already synced" and "Sync
complete"), but the caller only read it on the failure branch, so the
message was computed and thrown away: sync forty files, the popup
closes, and nothing confirms it happened.

Rather than push a non-error through the error channel, add a dedicated
successMsg field and setSuccessMsg action, rendered on its own line in
Colors.text.success. Cleared explicitly at the same points errorMsg is
(new sync, refresh, drive selection, cancel), matching the existing
no-auto-expiry idiom.

The transfer file counter was off by one. The engine emitted a 0-based
currentIndex from Stream.zipWithIndex while the popup renders it as a
done/total counter, so it read 0/3 while the first file copied. Three
signs this is a bug and not a choice: the engine's own log line already
used `i + 1`, the test double already emitted `i + 1`, and the counter
is display-only. Fixed the producer at all three emit sites so
production matches the already-tested contract, rather than adjusting
the consumer. Includes the "Tagging:" emit, which carried the same
off-by-one.

The ID3 artist tag was always blank. ZMTPODCAST.ZAUTHOR was populated
all along, but the query never asked for it: db.worker.ts selected only
p.ZTITLE, and groupEpisodesByPodcast hard-coded `author: ""`, which
reached the tag writer as `artist: ""`. Select p.ZAUTHOR, add author to
EpisodeRow and to the required Episode interface, carry it through the
row mapping, and read it off the grouped episodes with a fallback so a
blank is never written.

Also discard a copy whose tag write failed. The old code swallowed the
error and left the file on the drive with wrong or missing tags. It is
now logged and unlinked, so the next run re-copies and re-tags. This
preserves the existing rule that a failed tag never fails the sync.
Follows the previous sync fixes, addressing two gaps they left.

Make successMsg and errorMsg mutually exclusive in the store actions.
The two could previously both be set, so a red error and a green
"Sync complete" could render at once: after a successful sync, a
hotplug-triggered rescan could fail and set an error without clearing
the stale success. There are eight setErrorMsg call sites and any of
them could fire in that window, so the invariant now lives in the
setters rather than being remembered per call site. Three explicit
setSuccessMsg("") calls that paired with setErrorMsg("") are gone as
redundant.

Surface files discarded because tagging failed. Those are logged and
unlinked so the next run re-copies, but the sync still reported a
green "Sync complete" with nothing on the drive, which is its own kind
of lie. SyncProgress gains a required `discarded` counter, and
startSync folds the final count into the message: "Sync complete
(2 files discarded: tagging failed)". The terminal progress event uses
Stream.suspend rather than Stream.succeed, because the latter builds
its value when the stream is assembled, so it captured the count as 0
before any file was tagged.

Also log an unlink that fails. createPlan skips any destination that
exists, so a leftover half-tagged file is never re-copied; the failure
previously left no trace at all.

Finally, do not overwrite a failed rescan's error with a success
message: loadDrivePodcastsEffect now reports whether it succeeded, and
the success is only set when it did.

Convert the remaining `as SyncProgress` and `as SyncPlan` casts to
checked types, since an `as` assertion permits a missing property and
the new field would have compiled while emitting undefined.
useAppLogic had no test file, so the string the whole discarded-files
change exists to produce was unverified, as was the mutual exclusion of
successMsg and errorMsg along the paths the hook actually takes.

Extract syncSuccessMessage so the singular/plural branch can be pinned
directly, and cover the hook against the real store: a sync refused for
a missing drive sets an error and no success, a stale success does not
survive a new sync or a drive selection, and a bare rescan leaves the
status alone.

Note that loadDrivePodcasts is a rescan, not a selection: only
selectDrive clears the status, so the two are asserted separately.
Remove src/theme/index.ts and src/types/index.ts. Neither is imported:
every consumer reaches the specific module (@/theme/colors, @/types/drive)
rather than the barrel, so both re-exports only ever shadow the real modules
with a second import path.

Also clear rust/, 6.2G of cargo build output left in the working tree from
the gpui port. It held only dist/ and target/ with no Cargo.toml, so it was
purely regenerable artifacts, and it was not gitignored, which meant a
git add -A would have tried to stage 6.2G.
Error rendering used `err instanceof Error ? err.message : String(err)` in
six places. That is wrong for Effect: a Data.TaggedError subclass such as
DriveScanError IS an instance of Error, but its `message` is empty, so the
ternary produced "" — setErrorMsg("") renders nothing at all, and a failed
drive scan became a completely invisible error. A new test for the
post-sync rescan gate failed against exactly that, with state.errorMsg
empty after a scan that had demonstrably failed.

describeError prefers a real message, then a string or Error cause, then
the tag, and never returns something meaningless: an empty string, a bare
class name, "[object Object]" and "null" all become "Unknown error".
It unwraps an Effect Cause, so the sync-failure path reads "disk full"
instead of "Cause([Fail(SyncError (cause: Error: disk full))])", and names
an interruption "Cancelled" rather than dumping the wrapper.

useAppLogic now takes an optional service layer, defaulting to the live
one, so tests can drive the real control flow. Both run and runFork close
over that parameter; runFork previously reached for the module-level
AppLayer, which would have silently ignored an injected layer. This is the
only way to reach the failure paths, since they need an unplugged cable or
a broken database — the live scanDrive returns [] for a missing Podcasts
folder and swallows its own list errors, so it never fails on a tempdir.

Covered: the rescan gate in both directions, the discarded-files message,
the exclusive status channel, and describeError including a hostile
constructor name, since it runs inside catch handlers and the Errored
boundary where a throw would crash the app.
The existing syncEngineLive test asserts the artist reaching MetadataEditor,
which pins the ZAUTHOR chain only up to that boundary. Nothing checked the
file itself, so the one link that produced the original bug — the artist
frame node-id3 writes — was untested.

Writes a real file, tags it, then reads it back with node-id3 and asserts
on the tag that actually landed. Reverting groupEpisodesByPodcast to the
old `author: ""` makes this fail with an empty artist on disk, which is
what every file podapple had synced before the fix.

Also covers a missing and a whitespace-only ZAUTHOR, since the fallback has
to guarantee a blank is never written.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant