refactor: split core, app and desktop with dependency-injected services
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/corekeeps 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 theroleport 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 theapp-nodevitest project.apps/desktopkeeps the platform adapters (apps/desktop/src/services/) and React. - Two composition roots.
createCoreServices(adapters: CoreAdapters)(packages/core/src/create-core-services.ts) buildsCoreServicesandCoreInternalServices;createAppServices(core, host: AppHost)(packages/app/src/create-app-services.ts) buildsAppServices.apps/desktop/src/boot.tsruns them in order: the shared primitives (logger,clock, theIpcBridgeService), thencreateDesktopHost(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 inpackages/core/src/, the host ports and their default implementations inpackages/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:sha256Hexlives in@reflect/utils,pkceChallengeis called where it is used, and the mobile sync status is the functionmobileSyncStatus. - Three ports. Business services reach the shell only through
ProgressPort(where visible background work is reported; the app'sOperationsServiceimplements it),ActivityPort(resume, focus, visibility and background-task signals; the app'sAppLifecycleServiceimplements it) androle: 'main' | 'note-window'.packages/core/src/ports/activity-port.tsandprogress-port.ts.ProgressPort.fail(label, message)is the one-shot form;start()returns a handle for long operations. A note window'sTaskServicefollows the Rustindex:writtenbroadcast itself throughipc.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 unexportedXxxServiceImplclass (readonly #deps,#state, plain methods, no accessors, noprivate) andcreateXxxService(deps)returningnew XxxServiceImpl(deps). Files arepackages/<core|app>/src/<domain>/<name>-service.ts. OnlygetState/subscribeand the few members React passes unbound are function properties and arrow fields. Test doubles follow the same shape; a wrapper holds#innerand 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 whenGraphSession.signalaborts; 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 throughCoreAdapters.createGraphServices. - Graph open and boot split. Core
GraphSessionServiceis the state machine:attach,openRecent,createGraphAt,close,adopt, recents,deleteGraph,refreshIndex,park. AppGraphBootServiceholds the flows that know windows and dialogs:boot(main window, note window adoption, mobile),pickAndOpen,chooseGraph.IndexLifecycleService.followActivity()(core, started byGraphSessionService.attach()) suspends the index while a mobile main window is hidden.MobileOnboardingServiceis app layer. - Deep links split. Core
DeepLinkIntakeServiceowns thereflect://protocol: the OS source, the pending buffer,onUrl, andresolve(url)into a target (note, daily, search, tasks, capture spooled, none). AppDeepLinkRoutermaps a target to aRouteand navigates.Routeand 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/absolutePathForreturn data and the desktop writes the clipboard (lib/clipboard-actions.ts);GistPublishService.publishreturns the URL;attachFilesToNotetakes the files the desktop picked.ClipboardService,OpenerServiceandDialogServiceare app-layer services (core has no caller left). generation. Rust-boundary wrappers (NoteFileService,AssetStoreService,IndexStoreService,GitRepositoryService, ...) take an explicitgeneration, reads included. User-facing services capture the openGraphSessiononce at each public method's entry (requireGraphSession,writeGenerationFor). Desktop code passes no generations and has no "No graph is open" guards.- React.
CoreServicesProvider/useCoreServices()andAppServicesProvider/useAppServices()(apps/desktop/src/services/services-provider.tsx);useServiceState(service, read)reads a service's state.useMaybeGraph(),useMaybeGraphServices()anduseMaybeChatSession()are the versions that returnnullwithout a graph.CommandContextcarriesservices(core) andapp. Providers build their context from the service state and the service; there is noSettingsProvider. 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, solib/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.
NoteRowServiceis the overlay for pin, pin order, private, gist and trash;NoteActionsServicewrites 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 desktoplib/modules that only forwarded calls,useMainWindowEffect, the desktop query-cache patches,GitHostCredentialService,IpcBridgeService.transport/hasPluginEvents,withAppReviewDemo/withAppReviewDemoTranscription,withContactsLookups, the*ServiceFactorylayers, 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 intoeslint.config.js), the core browser vitest project and playwright. - Renamed.
WikiLinkSuggestionServiceisAutocompleteSuggestionService;type Unlistenlives inpackages/core/src/unlisten.ts;PlatformFacts.buildPlatformcarries the Tauri build target; the core container isCoreServices(notAppServices, which is now the app layer's).
Tests
@reflect/core/testingprovidescreateTestCoreServices(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/testingprovidescreateTestAppServices(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 oneemitResume()reaches both layers.- Desktop tests render through
renderInApp/renderHookInApp(apps/desktop/src/test-utils/render-in-app.tsx): both providers, the realGraphProviderand the real per-graph lifecycles over test services. Theapphandle is the core container,appServicesthe app layer. vitest-fail-on-consolecovers every package.restoreMocksis on.
| Before | After | |
|---|---|---|
Test files that install a fake bridge (setBridge() | 85 | 0 |
vi.mock( calls | 552 | about 80 |
hasBridge() / useBridgeReady() call sites | 61 | 0 |
| Entries in the console allowlist | 17 | 1 |
Function overloads in packages/ and apps/ | 13 | 0 |
| Services and doubles returning an object literal | all | 0 |
| "No graph is open" guards in desktop | 47 | 0 |
| Core services that read the window, the DOM lifecycle or the operations list | 37 | 0 (three ports) |
Exported names of @reflect/core | 1200 | about 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/corecannot import@tauri-apps/*,react,@tanstack/*,@reflect/app, or the apps;packages/appcannot import@tauri-apps/*,react,@tanstack/*, or the apps.- Core source cannot mention
isMainWindow,openNoteWindow,closeSecondaryWindowsor theRoutetype. - Neither package exports
default…/setDefault…instances or uses module-levellet. @tauri-apps/*is importable only fromapps/desktop/src/services/,boot.ts, andmain.tsx.- No
consolein app or package source; logging goes throughLoggerService. vi.mockonly for an explicit allowlist of module specifiers.- No function overloads, no accessors, no
private/protected/publicor 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.supportedisfalseon iOS and Android. Before, it wastruein 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
applyChangesbatches (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.indexGenerationcan benull: 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.templateBodyandbacklinks.getBacklinksWithContextthrow "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 singlesetHandler; 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 anddeepLinkForpassed the file generation where the index generation was expected, so those index writes could be rejected. packages/coregained adate-fnsdependency;packages/appdepends on@reflect/core,@reflect/utils,@ocavue/utils,date-fnsandzod.
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.tsandnote-editor.test.tsxhad timing races;month-picker-drawer.test.tsxdepended on state left by another file.- Browser tests:
@tauri-apps/api/corewas 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 newpackages/app/README.md.
Written by Claude Fable 5.1, Claude Opus 5.5 and Claude Sonnet 5.5.
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.