refactor: split core, app and desktop with dependency-injected services

Merged
OC
2d ago
+105694-66578

Replaces the process-global IPC bridge and module-level singletons on the TypeScript side with dependency-injected services, and splits the TypeScript side into three layers: @reflect/core (business logic and platform ports), @reflect/app (the app layer: windows, routes, commands, menus, update, theme, boot flows) and apps/desktop (adapters and React). No Rust logic changes (two test fixture paths moved).

What changed

  • Three packages. @reflect/core keeps business logic and the interfaces of platform primitives; it knows nothing about windows, routes, menus, pixels, React or Tauri (lint-enforced: core cannot import @reflect/app, and the role port is the only window fact core reads). New @reflect/app (packages/app) holds the app layer over core; it imports no React, TanStack or Tauri either, so it runs in the app-node vitest project. apps/desktop keeps the platform adapters (apps/desktop/src/services/) and React.
  • Two composition roots. createCoreServices(adapters: CoreAdapters) (packages/core/src/create-core-services.ts) builds CoreServices and CoreInternalServices; createAppServices(core, host: AppHost) (packages/app/src/create-app-services.ts) builds AppServices. apps/desktop/src/boot.ts runs them in order: the shared primitives (logger, clock, the IpcBridgeService), then createDesktopHost (window, lifecycle, operations and the other host-side services), then core with the host's services as its ports, then the app layer. Core layout: the two roots sit in packages/core/src/, the host ports and their default implementations in packages/core/src/ports/, everything else in a domain directory, and a file that exports a service is named after it. Pure computations are plain functions, not services: sha256Hex lives in @reflect/utils, pkceChallenge is called where it is used, and the mobile sync status is the function mobileSyncStatus.
  • Three ports. Business services reach the shell only through ProgressPort (where visible background work is reported; the app's OperationsService implements it), ActivityPort (resume, focus, visibility and background-task signals; the app's AppLifecycleService implements it) and role: 'main' | 'note-window'. packages/core/src/ports/activity-port.ts and progress-port.ts. ProgressPort.fail(label, message) is the one-shot form; start() returns a handle for long operations. A note window's TaskService follows the Rust index:written broadcast itself through ipc.listen.
  • Services are classes behind interfaces. Each capability is XxxServiceDeps (readonly fields typed with the full service type; the deps interface lists only the services the class uses), XxxService (the documented contract), an unexported XxxServiceImpl class (readonly #deps, # state, plain methods, no accessors, no private) and createXxxService(deps) returning new XxxServiceImpl(deps). Files are packages/<core|app>/src/<domain>/<name>-service.ts. Only getState / subscribe and the few members React passes unbound are function properties and arrow fields. Test doubles follow the same shape; a wrapper holds #inner and forwards each member, nothing spreads a service instance.
  • Per-graph lifecycles. createGraphServices(session, deps, flags) (core) starts backup, iCloud sync, capture, asset description, the chat session, iCloud downloads and the attachment catalog watch when a graph opens and disposes them when GraphSession.signal aborts; it takes booleans and does no branching. createAppGraphServices (packages/app/src/graph/graph-lifecycles.ts) decides the flags from the window role, the platform and two live settings, and the desktop passes it to core through CoreAdapters.createGraphServices.
  • Graph open and boot split. Core GraphSessionService is the state machine: attach, openRecent, createGraphAt, close, adopt, recents, deleteGraph, refreshIndex, park. App GraphBootService holds the flows that know windows and dialogs: boot (main window, note window adoption, mobile), pickAndOpen, chooseGraph. IndexLifecycleService.followActivity() (core, started by GraphSessionService.attach()) suspends the index while a mobile main window is hidden. MobileOnboardingService is app layer.
  • Deep links split. Core DeepLinkIntakeService owns the reflect:// protocol: the OS source, the pending buffer, onUrl, and resolve(url) into a target (note, daily, search, tasks, capture spooled, none). App DeepLinkRouter maps a target to a Route and navigates. Route and the navigation history (NavigationService), link activation (LinkNavigationService), commands, keymap, native menus, update, theme, telemetry, the palette and autocomplete data layers, the calendar events store, the daily view models and the mobile keyboard and share services are all @reflect/app.
  • Shell steps moved to callers. NoteActionsService.deepLinkFor / absolutePathFor return data and the desktop writes the clipboard (lib/clipboard-actions.ts); GistPublishService.publish returns the URL; attachFilesToNote takes the files the desktop picked. ClipboardService, OpenerService and DialogService are app-layer services (core has no caller left).
  • generation. Rust-boundary wrappers (NoteFileService, AssetStoreService, IndexStoreService, GitRepositoryService, ...) take an explicit generation, reads included. User-facing services capture the open GraphSession once at each public method's entry (requireGraphSession, writeGenerationFor). Desktop code passes no generations and has no "No graph is open" guards.
  • React. CoreServicesProvider / useCoreServices() and AppServicesProvider / useAppServices() (apps/desktop/src/services/services-provider.tsx); useServiceState(service, read) reads a service's state. useMaybeGraph(), useMaybeGraphServices() and useMaybeChatSession() are the versions that return null without a graph. CommandContext carries services (core) and app. Providers build their context from the service state and the service; there is no SettingsProvider. Hook signatures (useGraph, useSettings, useSync, useChatSession, useRouter) did not change.
  • Logic moved from desktop into core or app. Graph session and boot, index lifecycle, note sessions and open documents, rename and move, note actions, tasks (including the optimistic task rows: TaskService.getState() / subscribe / refresh() and ten write methods, so lib/tasks/ keeps only UI orchestration), navigation and deep links, link navigation, capture, audio memos, asset descriptions, chat, embeddings and semantic search, backup and iCloud sync, calendar and contacts, monetization, the Classic access recheck policy, the per-graph lifecycles above.
  • One optimistic mechanism. NoteRowService is the overlay for pin, pin order, private, gist and trash; NoteActionsService writes it and readers merge it. The app only invalidates React Query caches (apps/desktop/src/lib/query-invalidation.ts).
  • Removed. setBridge / getBridge / hasBridge / useBridgeReady, every free function that delegated to a global, the desktop lib/ modules that only forwarded calls, useMainWindowEffect, the desktop query-cache patches, GitHostCredentialService, IpcBridgeService.transport / hasPluginEvents, withAppReviewDemo / withAppReviewDemoTranscription, withContactsLookups, the *ServiceFactory layers, the React Query task cache, packages/core/scripts/check-exports.mjs (a whole-repo check does not belong in lint; the barrels were trimmed by hand), eslint.architecture.js (folded into eslint.config.js), the core browser vitest project and playwright.
  • Renamed. WikiLinkSuggestionService is AutocompleteSuggestionService; type Unlisten lives in packages/core/src/unlisten.ts; PlatformFacts.buildPlatform carries the Tauri build target; the core container is CoreServices (not AppServices, which is now the app layer's).

Tests

  • @reflect/core/testing provides createTestCoreServices(overrides) over the core root with test ports (createTestProgressPort(), createTestActivityPort()), plus hand-written doubles for the Rust, SQLite and plugin boundaries and recording wrappers over real services; where a real service's dependencies all have doubles, tests build the real service. @reflect/app/testing provides createTestAppServices(core, overrides) over the app root with a test host (createTestAppHost()); its lifecycle double drives the same activity port the core services subscribe to, so one emitResume() reaches both layers.
  • Desktop tests render through renderInApp / renderHookInApp (apps/desktop/src/test-utils/render-in-app.tsx): both providers, the real GraphProvider and the real per-graph lifecycles over test services. The app handle is the core container, appServices the app layer.
  • vitest-fail-on-console covers every package. restoreMocks is on.
BeforeAfter
Test files that install a fake bridge (setBridge()850
vi.mock( calls552about 80
hasBridge() / useBridgeReady() call sites610
Entries in the console allowlist171
Function overloads in packages/ and apps/130
Services and doubles returning an object literalall0
"No graph is open" guards in desktop470
Core services that read the window, the DOM lifecycle or the operations list370 (three ports)
Exported names of @reflect/core1200about 380

The remaining vi.mock calls are pure UI children, heavy children, Tauri adapter tests, and the browser extension. A lint rule holds the allowlist.

Lint rules that keep the shape (eslint.config.js)

  • packages/core cannot import @tauri-apps/*, react, @tanstack/*, @reflect/app, or the apps; packages/app cannot import @tauri-apps/*, react, @tanstack/*, or the apps.
  • Core source cannot mention isMainWindow, openNoteWindow, closeSecondaryWindows or the Route type.
  • Neither package exports default… / setDefault… instances or uses module-level let.
  • @tauri-apps/* is importable only from apps/desktop/src/services/, boot.ts, and main.tsx.
  • No console in app or package source; logging goes through LoggerService.
  • vi.mock only for an explicit allowlist of module specifiers.
  • No function overloads, no accessors, no private / protected / public or parameter properties (# instead).

Behavior changes to review

The migration moved code and changed how dependencies are obtained. These are the places where behavior is not identical:

  • Mobile boot: when the settings load fails, the app retries the load when it comes back to the foreground. Before, it stayed on "Couldn’t load your settings." (the query that used to re-run was removed with the global bridge).
  • A failed step in the quit or background flush, a failed GitHub keychain read, a failed calendar permission read, and a failed note:moved / import-progress listener registration are now logged. Before, they were swallowed.
  • UpdateService.supported is false on iOS and Android. Before, it was true in any native shell.
  • The hasBridge() / useBridgeReady() gates are gone. The IPC transport is installed before the first render, and the browser-dev transport queues early calls.
  • Index query invalidation also fires on watcher batches that changed no row and after an asset-description re-index (throttled); embedding and asset-description followers also hear applyChanges batches (hash-gated).
  • Contacts permission is read at boot, on focus while the integrations section is open, after a prompt, and before a meeting is added. Before, every consumer mount re-read it.
  • A pin made before the sidebar shelf loaded now reads the index and writes a numbered pin order. Before, it wrote a bare pinned: true.
  • A non-GitHub HTTPS remote no longer reads the keychain for a host credential (nothing could store one); its error text now suggests the SSH remote form.
  • GraphSession.indexGeneration can be null: a graph whose index failed to open still runs backup, iCloud sync, capture and chat, as before, and index-dependent actions no-op.
  • Index, graph, navigation, and chat state are one instance per webview, not one per provider mount. Subscriptions attach one microtask earlier.
  • Task rows: a failed read is not retried once after a second (React Query did); rows reset on any graph session change (before: on a root change only); the optimistic patch lands synchronously (before: a few microtasks later); overlapping writes of one kind stay pending until all finish; an asset-description pass no longer re-reads the task rows.
  • noteDocuments.readSource, templates.templateBody and backlinks.getBacklinksWithContext throw "No graph is open." without an open graph. The command palette note preview reads the open editor buffer first, then disk.
  • Deep links: the handler is a subscription (onUrl) instead of a single setHandler; the first subscriber drains the buffer.
  • Opening or creating a graph, and forgetting the open graph, close the note windows before the core call instead of after; a failed folder picker reports the error without parking on the chooser. When a graph closes, its own lifecycles are disposed before the asset-description and iCloud-download ones (the order used to be reversed).
  • Attaching files: the graph generation is read after the file picker closes, not before.
  • Fixed on the way (present on master): asset-description re-indexing and deepLinkFor passed the file generation where the index generation was expected, so those index writes could be rejected.
  • packages/core gained a date-fns dependency; packages/app depends on @reflect/core, @reflect/utils, @ocavue/utils, date-fns and zod.

Fixes found on the way

  • Mobile surface flags were wrong on the phone for code that read the platform from the provider; the boot facts now derive them from the app platform.
  • asset-description-service.test.ts and note-editor.test.tsx had timing races; month-picker-drawer.test.tsx depended on state left by another file.
  • Browser tests: @tauri-apps/api/core was optimized by Vite in the middle of a run, which reloaded the page. It is pre-optimized now.
  • CI: Chromium on Linux did not free finished test iframes, and the browser process died near 2.5 GB. The CI test step now makes Vitest collect garbage after every file.

Not in this PR

  • One-line JSDoc comments that predate this PR (about 1200 lines on master) are unchanged; the lines this PR adds are all multi-line.

Docs

  • docs/contributing/services.md (with a "Core, app, or desktop?" section), testing.md, adding-a-command.md, adding-a-setting.md, editor-architecture.md, multi-window.md, deep-links.md, docs/plans/architecture-conventions.md, AGENTS.md, packages/core/README.md, and the new packages/app/README.md.

Written by Claude Fable 5.1, Claude Opus 5.5 and Claude Sonnet 5.5.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by Sourcery

Introduce replaceable core services for time, logging, identifiers, and hashing, and migrate affected application code and tests to use them.

New Features:

  • Add clock, logger, identifier, and hashing service interfaces with replaceable default implementations.
  • Expose deterministic test service factories through the core testing package.
  • Allow Classic sign-in to use an injected identifier service for OAuth state and PKCE values.

Enhancements:

  • Centralize local date parsing and current-date handling in core clock utilities.
  • Route core diagnostics through the logger service instead of direct console calls.
  • Update desktop date state and tests to use the clock service and controllable test clocks.
  • Route content hashing and note ID generation through replaceable services.
  • Expand service-level and test-double coverage for time, logging, IDs, and hashing.

Tests:

  • Replace console spies and browser timer manipulation with recording logger and deterministic clock test services.

Chores:

  • Export the new platform services and testing entry point from @reflect/core.